mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
Compare commits
1 Commits
feat/play
...
feat/cloud
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4512385b1 |
@@ -1,2 +0,0 @@
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
NEON_API_KEY=
|
||||
2
.github/workflows/runner.yml
vendored
2
.github/workflows/runner.yml
vendored
@@ -7,7 +7,6 @@ on:
|
||||
paths:
|
||||
- "containers/runner.Containerfile"
|
||||
- "packages/scripts/**"
|
||||
- "packages/server/**"
|
||||
- ".github/workflows/runner.yml"
|
||||
schedule:
|
||||
- cron: 7 0 * * 1,3,6 # Regularly to keep that build cache warm
|
||||
@@ -17,7 +16,6 @@ on:
|
||||
- "containers/runner.Containerfile"
|
||||
- ".github/workflows/runner.yml"
|
||||
- "packages/scripts/**"
|
||||
- "packages/server/**"
|
||||
tags:
|
||||
- v*.*.*
|
||||
release:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,7 +8,6 @@ node_modules
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.sst
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "unifiedjs.vscode-mdx"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
24
.vscode/launch.json
vendored
Normal file
24
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Chrome",
|
||||
"request": "launch",
|
||||
"type": "chrome",
|
||||
"url": "http://localhost:5173",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"name": "dev.debug",
|
||||
"request": "launch",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"program": "${workspaceFolder}/node_modules/vite/bin/vite.js",
|
||||
"args": ["--mode", "ssr", "--force"]
|
||||
}
|
||||
]
|
||||
}
|
||||
36
.vscode/qwik-city.code-snippets
vendored
Normal file
36
.vscode/qwik-city.code-snippets
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"onRequest": {
|
||||
"scope": "javascriptreact,typescriptreact",
|
||||
"prefix": "qonRequest",
|
||||
"description": "onRequest function for a route index",
|
||||
"body": [
|
||||
"export const onRequest: RequestHandler = (request) => {",
|
||||
" $0",
|
||||
"};",
|
||||
],
|
||||
},
|
||||
"loader$": {
|
||||
"scope": "javascriptreact,typescriptreact",
|
||||
"prefix": "qloader$",
|
||||
"description": "loader$()",
|
||||
"body": ["export const $1 = routeLoader$(() => {", " $0", "});"],
|
||||
},
|
||||
"action$": {
|
||||
"scope": "javascriptreact,typescriptreact",
|
||||
"prefix": "qaction$",
|
||||
"description": "action$()",
|
||||
"body": ["export const $1 = routeAction$((data) => {", " $0", "});"],
|
||||
},
|
||||
"Full Page": {
|
||||
"scope": "javascriptreact,typescriptreact",
|
||||
"prefix": "qpage",
|
||||
"description": "Simple page component",
|
||||
"body": [
|
||||
"import { component$ } from '@builder.io/qwik';",
|
||||
"",
|
||||
"export default component$(() => {",
|
||||
" $0",
|
||||
"});",
|
||||
],
|
||||
},
|
||||
}
|
||||
78
.vscode/qwik.code-snippets
vendored
Normal file
78
.vscode/qwik.code-snippets
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"Qwik component (simple)": {
|
||||
"scope": "javascriptreact,typescriptreact",
|
||||
"prefix": "qcomponent$",
|
||||
"description": "Simple Qwik component",
|
||||
"body": [
|
||||
"export const ${1:${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/}} = component$(() => {",
|
||||
" return <${2:div}>$4</$2>",
|
||||
"});",
|
||||
],
|
||||
},
|
||||
"Qwik component (props)": {
|
||||
"scope": "typescriptreact",
|
||||
"prefix": "qcomponent$ + props",
|
||||
"description": "Qwik component w/ props",
|
||||
"body": [
|
||||
"export interface ${1:${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/}}Props {",
|
||||
" $2",
|
||||
"}",
|
||||
"",
|
||||
"export const $1 = component$<$1Props>((props) => {",
|
||||
" const ${2:count} = useSignal(0);",
|
||||
" return (",
|
||||
" <${3:div} on${4:Click}$={(ev) => {$5}}>",
|
||||
" $6",
|
||||
" </${3}>",
|
||||
" );",
|
||||
"});",
|
||||
],
|
||||
},
|
||||
"Qwik signal": {
|
||||
"scope": "javascriptreact,typescriptreact",
|
||||
"prefix": "quseSignal",
|
||||
"description": "useSignal() declaration",
|
||||
"body": ["const ${1:foo} = useSignal($2);", "$0"],
|
||||
},
|
||||
"Qwik store": {
|
||||
"scope": "javascriptreact,typescriptreact",
|
||||
"prefix": "quseStore",
|
||||
"description": "useStore() declaration",
|
||||
"body": ["const ${1:state} = useStore({", " $2", "});", "$0"],
|
||||
},
|
||||
"$ hook": {
|
||||
"scope": "javascriptreact,typescriptreact",
|
||||
"prefix": "q$",
|
||||
"description": "$() function hook",
|
||||
"body": ["$(() => {", " $0", "});", ""],
|
||||
},
|
||||
"useVisibleTask": {
|
||||
"scope": "javascriptreact,typescriptreact",
|
||||
"prefix": "quseVisibleTask",
|
||||
"description": "useVisibleTask$() function hook",
|
||||
"body": ["useVisibleTask$(({ track }) => {", " $0", "});", ""],
|
||||
},
|
||||
"useTask": {
|
||||
"scope": "javascriptreact,typescriptreact",
|
||||
"prefix": "quseTask$",
|
||||
"description": "useTask$() function hook",
|
||||
"body": [
|
||||
"useTask$(({ track }) => {",
|
||||
" track(() => $1);",
|
||||
" $0",
|
||||
"});",
|
||||
"",
|
||||
],
|
||||
},
|
||||
"useResource": {
|
||||
"scope": "javascriptreact,typescriptreact",
|
||||
"prefix": "quseResource$",
|
||||
"description": "useResource$() declaration",
|
||||
"body": [
|
||||
"const $1 = useResource$(({ track, cleanup }) => {",
|
||||
" $0",
|
||||
"});",
|
||||
"",
|
||||
],
|
||||
},
|
||||
}
|
||||
11
.vscode/settings.json
vendored
Normal file
11
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"material-icon-theme.activeIconPack": "qwik",
|
||||
"emmet.includeLanguages": {
|
||||
"typescriptreact": "html"
|
||||
},
|
||||
"emmet.preferences": {
|
||||
// to ensure closing tags are used (e.g. <img/> not just <img> like in HTML)
|
||||
// https://github.com/microsoft/vscode/commit/083bf9020407ea5a91199eb1f0b373859df8d600#diff-88456bc9b7caa2f8126aea0107b4671db0f094961aaf39a7c689f890e23aaaba
|
||||
"output.selfClosingStyle": "xhtml"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# What is this?
|
||||
|
||||
This is the part of the docs dedicated for the team working on Nestri
|
||||
@@ -1,27 +0,0 @@
|
||||
# Setup
|
||||
|
||||
- Install bun [https://bun.sh/](https://bun.sh/)
|
||||
- Generate your Cloudflare token from [here](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22memberships%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22user_details%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22workers_kv_storage%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22workers_r2%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22workers_routes%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22workers_scripts%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22workers_tail%22%2C%22type%22%3A%22read%22%7D%5D&name=sst&accountId=*&zoneId=all)
|
||||
- save it to a `.env` file like this
|
||||
```
|
||||
CLOUDFLARE_API_TOKEN=xxx
|
||||
```
|
||||
- Copy this to your `~/.aws/config` file
|
||||
```
|
||||
[sso-session nestri]
|
||||
sso_start_url = https://nestri.awsapps.com/start
|
||||
sso_region = us-east-1
|
||||
|
||||
[profile nestri-dev]
|
||||
sso_session = nestri
|
||||
sso_account_id = 535002871375
|
||||
sso_role_name = AdministratorAccess
|
||||
region = us-east-1
|
||||
|
||||
[profile nestri-production]
|
||||
sso_session = nestri
|
||||
sso_account_id = 209479283398
|
||||
sso_role_name = AdministratorAccess
|
||||
region = us-east-1
|
||||
```
|
||||
- You need to login once a day with `bun sso` in root
|
||||
@@ -1,2 +0,0 @@
|
||||
title: 'Nestri Internals'
|
||||
icon: heroicons-outline:bookmark-alt
|
||||
@@ -35,9 +35,7 @@
|
||||
"@builder.io/qwik": "^1.8.0",
|
||||
"@builder.io/qwik-city": "^1.8.0",
|
||||
"@builder.io/qwik-react": "0.5.0",
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.0.1",
|
||||
"@fontsource/geist-mono": "^5.1.0",
|
||||
"@fontsource/geist-sans": "^5.1.0",
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.1.1",
|
||||
"@fontsource-variable/mona-sans": "^5.0.1",
|
||||
"@modular-forms/qwik": "^0.29.0",
|
||||
"@nestri/input": "*",
|
||||
@@ -63,7 +61,6 @@
|
||||
"prettier": "3.3.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"semver": "^7.7.1",
|
||||
"typescript": "5.4.5",
|
||||
"undici": "*",
|
||||
"valibot": "^0.42.1",
|
||||
|
||||
84
cloud/ubuntu-cloud-init.yaml
Normal file
84
cloud/ubuntu-cloud-init.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
#cloud-config
|
||||
# Cloud-init script to install NVIDIA 570 drivers on Ubuntu
|
||||
# Tested with TensorDock Ubuntu 22.04 instances
|
||||
|
||||
# Install required packages
|
||||
packages:
|
||||
- build-essential
|
||||
- linux-headers-generic
|
||||
- dkms
|
||||
|
||||
# Our scripts
|
||||
write_files:
|
||||
- path: /root/prevent_driver_update.sh
|
||||
encoding: b64
|
||||
permissions: '0755'
|
||||
content: ZHBrZy1xdWVyeSAtVyAtLXNob3dmb3JtYXQ9JyR7UGFja2FnZX0gJHtTdGF0dXN9XG4nIHwgZ3JlcCAtdiBkZWluc3RhbGwgfCBhd2sgJ3sgcHJpbnQgJDEgfScgfCBncmVwIC1FICdudmlkaWEuKi1bMC05XSskJyB8IHhhcmdzIC1yIC1MIDEgc3VkbyBhcHQtbWFyayBob2xk
|
||||
# Blacklist nouveau just in case
|
||||
- path: /etc/modprobe.d/blacklist-nouveau.conf
|
||||
content: |
|
||||
blacklist nouveau
|
||||
# Enable modesetting for NVIDIA drivers
|
||||
- path: /etc/modprobe.d/nvidia.conf
|
||||
content: |
|
||||
options nvidia-drm modeset=1
|
||||
# Main setup script
|
||||
- path: /root/setup_nvidia.sh
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/bin/bash
|
||||
echo "Starting Nestri NVIDIA driver setup..."
|
||||
|
||||
echo "Purging old NVIDIA packages..."
|
||||
apt remove --purge -y '*nvidia*'
|
||||
apt autoremove -y
|
||||
|
||||
echo "Unloading conflicting kernel modules..."
|
||||
modprobe -r nouveau 2>/dev/null || true
|
||||
modprobe -r nvidia_drm 2>/dev/null || true
|
||||
modprobe -r nvidia_modeset 2>/dev/null || true
|
||||
modprobe -r nvidia 2>/dev/null || true
|
||||
|
||||
# Update initramfs to apply blacklist
|
||||
update-initramfs -u
|
||||
|
||||
echo "Installing NVIDIA 570 driver..."
|
||||
wget https://us.download.nvidia.com/XFree86/Linux-x86_64/570.86.16/NVIDIA-Linux-x86_64-570.86.16.run -O /root/NVIDIA-Linux-x86_64-570.86.16.run
|
||||
chmod +x /root/NVIDIA-Linux-x86_64-570.86.16.run
|
||||
# Install without building kernel module immediately, then build with DKMS
|
||||
/root/NVIDIA-Linux-x86_64-570.86.16.run --silent --dkms
|
||||
# Clean up
|
||||
rm /root/NVIDIA-Linux-x86_64-570.86.16.run
|
||||
|
||||
# Prevent auto-update from nuking driver
|
||||
echo "Making the new driver held version..."
|
||||
bash /root/prevent_driver_update.sh
|
||||
|
||||
echo "Loading new NVIDIA modules..."
|
||||
modprobe nvidia
|
||||
modprobe nvidia_modeset
|
||||
modprobe nvidia_drm
|
||||
|
||||
# Re-install container toolkit
|
||||
echo "Re-installing NVIDIA container toolkit..."
|
||||
apt install -y nvidia-container-toolkit
|
||||
|
||||
echo "Configuring NVIDIA container toolkit..."
|
||||
nvidia-ctk runtime configure --runtime=docker
|
||||
# Restart Docker only if necessary
|
||||
if ! nvidia-smi > /dev/null 2>&1; then
|
||||
echo "Restarting Docker to apply GPU changes..."
|
||||
systemctl restart docker
|
||||
fi
|
||||
|
||||
echo "Setting up permissions..."
|
||||
chmod 777 /dev/dri/* 2>/dev/null || true
|
||||
|
||||
cd /home/user/ && mkdir -p nestri && chown user:user nestri
|
||||
docker run --security-opt="seccomp=unconfined" --security-opt="apparmor=unconfined" --name=nestri -d --shm-size=6g --runtime=nvidia --gpus=all -e RELAY_URL='https://relay.dathorse.com' -e NESTRI_ROOM=cloudinit123 -e RESOLUTION=1920x1080 -e FRAMERATE=60 -e GST_DEBUG=3 -e NESTRI_PARAMS='--verbose=true --video-codec=h264 --video-bitrate=6000 --video-bitrate-max=8000' -v /home/user/nestri:/home/nestri ghcr.io/datcaptainhorse/nestri-cachyos:latest-v3
|
||||
|
||||
echo "Nestri NVIDIA driver setup complete!"
|
||||
|
||||
# Run setup script on first launch
|
||||
runcmd:
|
||||
- /root/setup_nvidia.sh
|
||||
@@ -1,29 +1,20 @@
|
||||
FROM docker.io/golang:1.24-alpine AS go-build
|
||||
FROM docker.io/golang:1.23-alpine AS go-build
|
||||
WORKDIR /builder
|
||||
COPY packages/relay/ /builder/
|
||||
RUN go build
|
||||
|
||||
FROM docker.io/golang:1.24-alpine
|
||||
FROM docker.io/golang:1.23-alpine
|
||||
COPY --from=go-build /builder/relay /relay/relay
|
||||
WORKDIR /relay
|
||||
|
||||
# TODO: Switch running layer to just alpine (doesn't need golang dev stack)
|
||||
|
||||
# ENV flags
|
||||
ENV VERBOSE=false
|
||||
ENV DEBUG=false
|
||||
ENV ENDPOINT_PORT=8088
|
||||
ENV WEBRTC_UDP_START=10000
|
||||
ENV WEBRTC_UDP_END=20000
|
||||
ENV STUN_SERVER="stun.l.google.com:19302"
|
||||
ENV WEBRTC_UDP_MUX=8088
|
||||
ENV WEBRTC_NAT_IPS=""
|
||||
ENV AUTO_ADD_LOCAL_IP=true
|
||||
ENV TLS_CERT=""
|
||||
ENV TLS_KEY=""
|
||||
|
||||
EXPOSE $ENDPOINT_PORT
|
||||
EXPOSE $WEBRTC_UDP_START-$WEBRTC_UDP_END/udp
|
||||
EXPOSE $WEBRTC_UDP_MUX/udp
|
||||
|
||||
ENTRYPOINT ["/relay/relay"]
|
||||
@@ -1,18 +1,10 @@
|
||||
# Container build arguments #
|
||||
ARG BASE_IMAGE=docker.io/cachyos/cachyos:latest
|
||||
|
||||
#******************************************************************************
|
||||
# Base Stage - Updates system packages
|
||||
#******************************************************************************
|
||||
FROM ${BASE_IMAGE} AS base
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/pacman/pkg \
|
||||
pacman --noconfirm -Syu
|
||||
|
||||
#******************************************************************************
|
||||
# Base Builder Stage - Prepares core build environment
|
||||
#******************************************************************************
|
||||
FROM base AS base-builder
|
||||
FROM ${BASE_IMAGE} AS base-builder
|
||||
|
||||
# Environment setup for Rust and Cargo
|
||||
ENV CARGO_HOME=/usr/local/cargo \
|
||||
@@ -22,12 +14,9 @@ ENV CARGO_HOME=/usr/local/cargo \
|
||||
|
||||
# Install build essentials and caching tools
|
||||
RUN --mount=type=cache,target=/var/cache/pacman/pkg \
|
||||
pacman -Sy --noconfirm mold rustup && \
|
||||
pacman -Sy --noconfirm mold rust && \
|
||||
mkdir -p "${ARTIFACTS}"
|
||||
|
||||
# Install latest Rust using rustup
|
||||
RUN rustup default stable
|
||||
|
||||
# Install cargo-chef with proper caching
|
||||
RUN --mount=type=cache,target=${CARGO_HOME}/registry \
|
||||
cargo install -j $(nproc) cargo-chef cargo-c --locked
|
||||
@@ -39,8 +28,7 @@ FROM base-builder AS nestri-server-deps
|
||||
WORKDIR /builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN --mount=type=cache,target=/var/cache/pacman/pkg \
|
||||
pacman -Sy --noconfirm meson pkgconf cmake git gcc make \
|
||||
RUN pacman -Sy --noconfirm meson pkgconf cmake git gcc make \
|
||||
gstreamer gst-plugins-base gst-plugins-good gst-plugin-rswebrtc
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
@@ -119,7 +107,7 @@ RUN --mount=type=cache,target=${CARGO_HOME}/registry \
|
||||
#******************************************************************************
|
||||
# Final Runtime Stage
|
||||
#******************************************************************************
|
||||
FROM base AS runtime
|
||||
FROM ${BASE_IMAGE} AS runtime
|
||||
|
||||
### System Configuration ###
|
||||
RUN sed -i \
|
||||
@@ -129,28 +117,27 @@ RUN sed -i \
|
||||
dirmngr </dev/null > /dev/null 2>&1
|
||||
|
||||
### Package Installation ###
|
||||
RUN pacman --noconfirm -Sy && \
|
||||
# Core system components
|
||||
RUN --mount=type=cache,target=/var/cache/pacman/pkg \
|
||||
pacman -Sy --needed --noconfirm \
|
||||
vulkan-intel lib32-vulkan-intel vpl-gpu-rt mesa \
|
||||
pacman -S --needed --noconfirm \
|
||||
archlinux-keyring vulkan-intel lib32-vulkan-intel mesa \
|
||||
steam steam-native-runtime \
|
||||
sudo xorg-xwayland seatd libinput labwc wlr-randr mangohud \
|
||||
libssh2 curl wget \
|
||||
sudo xorg-xwayland labwc wlr-randr mangohud \
|
||||
pipewire pipewire-pulse pipewire-alsa wireplumber \
|
||||
noto-fonts-cjk supervisor jq chwd lshw pacman-contrib && \
|
||||
# GStreamer stack
|
||||
pacman -Sy --needed --noconfirm \
|
||||
pacman -S --needed --noconfirm \
|
||||
gstreamer gst-plugins-base gst-plugins-good \
|
||||
gst-plugins-bad gst-plugin-pipewire \
|
||||
gst-plugin-webrtchttp gst-plugin-rswebrtc gst-plugin-rsrtp \
|
||||
gst-plugin-va gst-plugin-qsv && \
|
||||
gst-plugin-rswebrtc gst-plugin-rsrtp && \
|
||||
# Cleanup
|
||||
paccache -rk1 && \
|
||||
rm -rf /usr/share/{info,man,doc}/*
|
||||
|
||||
### Application Installation ###
|
||||
ARG LUDUSAVI_VERSION="0.28.0"
|
||||
RUN curl -fsSL -o ludusavi.tar.gz \
|
||||
RUN pacman -Sy --noconfirm --needed curl && \
|
||||
curl -fsSL -o ludusavi.tar.gz \
|
||||
"https://github.com/mtkennerly/ludusavi/releases/download/v${LUDUSAVI_VERSION}/ludusavi-v${LUDUSAVI_VERSION}-linux.tar.gz" && \
|
||||
tar -xzvf ludusavi.tar.gz && \
|
||||
mv ludusavi /usr/bin/ && \
|
||||
|
||||
88
infra/api.ts
88
infra/api.ts
@@ -1,82 +1,54 @@
|
||||
import { bus } from "./bus";
|
||||
import { authFingerprintKey } from "./auth";
|
||||
import { domain } from "./dns";
|
||||
import { email } from "./email";
|
||||
import { secret } from "./secret";
|
||||
import { database } from "./database";
|
||||
|
||||
sst.Linkable.wrap(random.RandomString, (resource) => ({
|
||||
properties: {
|
||||
value: resource.result,
|
||||
},
|
||||
}));
|
||||
import { secret } from "./secrets"
|
||||
// import { party } from "./party"
|
||||
import { gpuTaskDefinition, ecsCluster } from "./cluster";
|
||||
|
||||
export const urls = new sst.Linkable("Urls", {
|
||||
properties: {
|
||||
api: "https://api." + domain,
|
||||
auth: "https://auth." + domain,
|
||||
site: $dev ? "http://localhost:4321" : "https://" + domain,
|
||||
},
|
||||
});
|
||||
|
||||
export const authFingerprintKey = new random.RandomString(
|
||||
"AuthFingerprintKey",
|
||||
{
|
||||
length: 32,
|
||||
},
|
||||
);
|
||||
export const kv = new sst.cloudflare.Kv("CloudflareAuthKV")
|
||||
|
||||
export const auth = new sst.aws.Auth("Auth", {
|
||||
issuer: {
|
||||
timeout: "3 minutes",
|
||||
handler: "./packages/functions/src/auth.handler",
|
||||
export const auth = new sst.cloudflare.Worker("Auth", {
|
||||
link: [
|
||||
bus,
|
||||
email,
|
||||
database,
|
||||
kv,
|
||||
urls,
|
||||
authFingerprintKey,
|
||||
secret.PolarSecret,
|
||||
secret.InstantAdminToken,
|
||||
secret.InstantAppId,
|
||||
secret.LoopsApiKey,
|
||||
secret.GithubClientID,
|
||||
secret.DiscordClientID,
|
||||
secret.GithubClientSecret,
|
||||
secret.DiscordClientID,
|
||||
secret.DiscordClientSecret,
|
||||
],
|
||||
permissions: [
|
||||
{
|
||||
actions: ["ses:SendEmail"],
|
||||
resources: ["*"],
|
||||
},
|
||||
],
|
||||
},
|
||||
domain: {
|
||||
name: "auth." + domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
},
|
||||
})
|
||||
handler: "./packages/functions/src/auth.ts",
|
||||
url: true,
|
||||
domain: "auth." + domain
|
||||
});
|
||||
|
||||
export const apiFunction = new sst.aws.Function("ApiFn", {
|
||||
handler: "packages/functions/src/api/index.handler",
|
||||
export const api = new sst.cloudflare.Worker("Api", {
|
||||
link: [
|
||||
bus,
|
||||
urls,
|
||||
database,
|
||||
secret.PolarSecret,
|
||||
ecsCluster,
|
||||
gpuTaskDefinition,
|
||||
authFingerprintKey,
|
||||
secret.LoopsApiKey,
|
||||
secret.InstantAppId,
|
||||
secret.AwsAccessKey,
|
||||
secret.AwsSecretKey,
|
||||
secret.InstantAdminToken,
|
||||
],
|
||||
timeout: "3 minutes",
|
||||
streaming: !$dev,
|
||||
url: true
|
||||
})
|
||||
|
||||
export const api = new sst.aws.Router("Api", {
|
||||
routes: {
|
||||
"/*": apiFunction.url
|
||||
},
|
||||
domain: {
|
||||
name: "api." + domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
},
|
||||
url: true,
|
||||
handler: "./packages/functions/src/api/index.ts",
|
||||
domain: "api." + domain
|
||||
})
|
||||
|
||||
export const outputs = {
|
||||
auth: auth.url,
|
||||
api: api.url,
|
||||
};
|
||||
api: api.url
|
||||
}
|
||||
12
infra/auth.ts
Normal file
12
infra/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const authFingerprintKey = new random.RandomString(
|
||||
"AuthFingerprintKey",
|
||||
{
|
||||
length: 32,
|
||||
},
|
||||
);
|
||||
|
||||
sst.Linkable.wrap(random.RandomString, (resource) => ({
|
||||
properties: {
|
||||
value: resource.result,
|
||||
},
|
||||
}));
|
||||
20
infra/bus.ts
20
infra/bus.ts
@@ -1,20 +0,0 @@
|
||||
import { email } from "./email";
|
||||
import { allSecrets } from "./secret";
|
||||
import { database } from "./database";
|
||||
|
||||
export const bus = new sst.aws.Bus("Bus");
|
||||
|
||||
bus.subscribe("Event", {
|
||||
handler: "./packages/functions/src/event/event.handler",
|
||||
link: [
|
||||
database,
|
||||
email,
|
||||
...allSecrets],
|
||||
timeout: "5 minutes",
|
||||
permissions: [
|
||||
{
|
||||
actions: ["ses:SendEmail"],
|
||||
resources: ["*"],
|
||||
},
|
||||
],
|
||||
});
|
||||
155
infra/cluster.ts
Normal file
155
infra/cluster.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { sshKey } from "./ssh";
|
||||
import { authFingerprintKey } from "./auth";
|
||||
|
||||
export const ecsCluster = new aws.ecs.Cluster("NestriGPUCluster", {
|
||||
name: "NestriGPUCluster",
|
||||
});
|
||||
|
||||
const ecsInstanceRole = new aws.iam.Role("NestriGPUInstanceRole", {
|
||||
name: "GPUAssumeRoleProd",
|
||||
assumeRolePolicy: JSON.stringify({
|
||||
Version: "2012-10-17",
|
||||
Statement: [{
|
||||
Action: "sts:AssumeRole",
|
||||
Principal: {
|
||||
Service: "ec2.amazonaws.com",
|
||||
},
|
||||
Effect: "Allow",
|
||||
Sid: "",
|
||||
}],
|
||||
}),
|
||||
});
|
||||
|
||||
new aws.iam.RolePolicyAttachment("NestriGPUInstancePolicyAttachment", {
|
||||
role: ecsInstanceRole.name,
|
||||
policyArn: "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role",
|
||||
});
|
||||
|
||||
const ecsInstanceProfile = new aws.iam.InstanceProfile("NestriGPUInstanceProfile", {
|
||||
role: ecsInstanceRole.name,
|
||||
});
|
||||
|
||||
// const server = new aws.ec2.Instance("NestriGPU", {
|
||||
// instanceType: aws.ec2.InstanceType.G4dn_XLarge,
|
||||
// ami: "ami-046a6af96ef510bb6",//Fedora cloud
|
||||
// keyName: sshKey.keyName,
|
||||
// instanceMarketOptions: {
|
||||
// marketType: "spot",
|
||||
// spotOptions: {
|
||||
// maxPrice: "0.2",
|
||||
// spotInstanceType: "persistent",
|
||||
// instanceInterruptionBehavior: "stop"
|
||||
// },
|
||||
// },
|
||||
// iamInstanceProfile: ecsInstanceProfile,
|
||||
// });
|
||||
|
||||
const logGroup = new aws.cloudwatch.LogGroup("NestriGPULogGroup", {
|
||||
name: "/ecs/nestri-gpu-prod",
|
||||
retentionInDays: 7,
|
||||
});
|
||||
|
||||
// Create a Task Definition for the ECS service to test it
|
||||
export const gpuTaskDefinition = new aws.ecs.TaskDefinition("NestriGPUTask", {
|
||||
family: "NestriGPUTaskProd",
|
||||
requiresCompatibilities: ["EC2"],
|
||||
volumes: [
|
||||
{
|
||||
name: "host",
|
||||
hostPath: "/mnt/"
|
||||
// efsVolumeConfiguration: {
|
||||
// fileSystemId: storage.id,
|
||||
// authorizationConfig: { accessPointId: storage.accessPoint },
|
||||
// transitEncryption: "ENABLED",
|
||||
// }
|
||||
}
|
||||
],
|
||||
containerDefinitions: authFingerprintKey.result.apply(v => JSON.stringify([{
|
||||
"essential": true,
|
||||
"name": "nestri",
|
||||
"memory": 1024,
|
||||
"cpu": 200,
|
||||
"gpu": 1,
|
||||
"image": "ghcr.io/nestrilabs/nestri/runner:nightly",
|
||||
"environment": [
|
||||
{
|
||||
"name": "RESOLUTION",
|
||||
"value": "1920x1080"
|
||||
},
|
||||
{
|
||||
"name": "AUTH_FINGERPRINT",
|
||||
"value": v
|
||||
},
|
||||
{
|
||||
"name": "FRAMERATE",
|
||||
"value": "60"
|
||||
},
|
||||
{
|
||||
"name": "NESTRI_ROOM",
|
||||
"value": "aws-testing"
|
||||
},
|
||||
{
|
||||
"name": "RELAY_URL",
|
||||
"value": "https://relay.dathorse.com"
|
||||
},
|
||||
{
|
||||
"name": "NESTRI_PARAMS",
|
||||
"value": "--verbose=true --video-codec=h264 --video-bitrate=4000 --video-bitrate-max=6000 --gpu-card-path=/dev/dri/card0"
|
||||
},
|
||||
],
|
||||
"mountPoints": [{ "containerPath": "/home/nestri", "sourceVolume": "host" }],
|
||||
"disableNetworking": false,
|
||||
"linuxParameter": {
|
||||
"sharedMemorySize": 5120
|
||||
},
|
||||
"logConfiguration": {
|
||||
"logDriver": "awslogs",
|
||||
"options": {
|
||||
"awslogs-group": "/ecs/nestri-gpu-prod",
|
||||
"awslogs-region": "us-east-1",
|
||||
"awslogs-stream-prefix": "nestri-gpu-task"
|
||||
}
|
||||
}
|
||||
}]))
|
||||
});
|
||||
|
||||
sst.Linkable.wrap(aws.ecs.TaskDefinition, (resource) => ({
|
||||
properties: {
|
||||
value: resource.arn,
|
||||
},
|
||||
}));
|
||||
|
||||
sst.Linkable.wrap(aws.ecs.Cluster, (resource) => ({
|
||||
properties: {
|
||||
value: resource.arn,
|
||||
},
|
||||
}));
|
||||
|
||||
// userData: $interpolate`#!/bin/bash
|
||||
// sudo rm /etc/sysconfig/docker
|
||||
// echo DAEMON_MAXFILES=1048576 | sudo tee -a /etc/sysconfig/docker
|
||||
// echo DAEMON_PIDFILE_TIMEOUT=10 | sud o tee -a /etc/sysconfig/docker
|
||||
// echo OPTIONS="--default-ulimit nofile=32768:65536" | sudo tee -a /etc/sysconfig/docker
|
||||
// sudo tee "/etc/docker/daemon.json" > /dev/null <<EOF
|
||||
// {
|
||||
// "default-runtime": "nvidia",
|
||||
// "runtimes": {
|
||||
// "nvidia": {
|
||||
// "path": "/usr/bin/nvidia-container-runtime",
|
||||
// "runtimeArgs": []
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// EOF
|
||||
// sudo systemctl restart docker
|
||||
// echo ECS_CLUSTER='${ecsCluster.name}' | sudo tee -a /etc/ecs/ecs.config
|
||||
// echo ECS_ENABLE_GPU_SUPPORT=true | sudo tee -a /etc/ecs/ecs.config
|
||||
// echo ECS_CONTAINER_STOP_TIMEOUT=3h | sudo tee -a /etc/ecs/ecs.config
|
||||
// echo ECS_ENABLE_SPOT_INSTANCE_DRAINING=true | sudo tee -a /etc/ecs/ecs.config
|
||||
// `,
|
||||
|
||||
// This is used for requesting a container to be deployed on AWS
|
||||
// const queue = new sst.aws.Queue("PartyQueue", { fifo: true });
|
||||
|
||||
// queue.subscribe({ handler: "packages/functions/src/party/subscriber.handler", permissions:{}, link:[taskF]})
|
||||
// const authRes = $interpolate`${authFingerprintKey.result}`
|
||||
@@ -1,40 +0,0 @@
|
||||
//Created manually from the dashboard and shared with the whole team/org
|
||||
const dbProject = neon.getProjectOutput({
|
||||
id: "black-sky-26872933"
|
||||
})
|
||||
|
||||
const dbBranchId = $app.stage !== "production" ?
|
||||
new neon.Branch("NeonBranch", {
|
||||
parentId: dbProject.defaultBranchId,
|
||||
projectId: dbProject.id,
|
||||
name: $app.stage,
|
||||
}).id : dbProject.defaultBranchId
|
||||
|
||||
const dbEndpoint = new neon.Endpoint("NeonEndpoint", {
|
||||
projectId: dbProject.id,
|
||||
branchId: dbBranchId,
|
||||
poolerEnabled: true,
|
||||
type: "read_write",
|
||||
})
|
||||
|
||||
const dbRole = new neon.Role("NeonRole", {
|
||||
name: "admin",
|
||||
branchId: dbBranchId,
|
||||
projectId: dbProject.id,
|
||||
})
|
||||
|
||||
const db = new neon.Database("NeonDatabase", {
|
||||
branchId: dbBranchId,
|
||||
projectId: dbProject.id,
|
||||
ownerName: dbRole.name,
|
||||
name: `nestri-${$app.stage}`,
|
||||
})
|
||||
|
||||
export const database = new sst.Linkable("Database", {
|
||||
properties: {
|
||||
name: db.name,
|
||||
user: dbRole.name,
|
||||
host: dbEndpoint.host,
|
||||
password: dbRole.password,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { domain } from "./dns";
|
||||
|
||||
export const email = new sst.aws.Email("Mail",{
|
||||
sender: domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
})
|
||||
33
infra/party.ts
Normal file
33
infra/party.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// This is for the websocket/MQTT endpoint that helps the API communicate with the container
|
||||
// [API] <-> party <-websocket-> container
|
||||
// The container is it's own this, and can listen to Websocket connections to start or stop a Steam Game
|
||||
|
||||
// import { authFingerprintKey } from "./auth";
|
||||
// import { ecsCluster, gpuTaskDefinition } from "./cluster";
|
||||
|
||||
// export const party = new sst.aws.Realtime("Party", {
|
||||
// authorizer: "packages/functions/src/party/authorizer.handler"
|
||||
// });
|
||||
|
||||
// export const partyFn = new sst.aws.Function("NestriPartyFn", {
|
||||
// handler: "packages/functions/src/party/create.handler",
|
||||
// // link: [queue],
|
||||
// link: [authFingerprintKey],
|
||||
// environment: {
|
||||
// TASK_DEFINITION: gpuTaskDefinition.arn,
|
||||
// // AUTH_FINGERPRINT: authFingerprintKey.result,
|
||||
// ECS_CLUSTER: ecsCluster.arn,
|
||||
// },
|
||||
// permissions: [
|
||||
// {
|
||||
// effect: "allow",
|
||||
// actions: ["ecs:RunTask"],
|
||||
// resources: [gpuTaskDefinition.arn]
|
||||
// }
|
||||
// ],
|
||||
// url: true,
|
||||
// });
|
||||
|
||||
// export const outputs = {
|
||||
// partyFunction: partyFn.url
|
||||
// }
|
||||
179
infra/relay.ts
Normal file
179
infra/relay.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// const vpc = new sst.aws.Vpc("NestriRelayVpc", { az: 2 })
|
||||
// import { subnet1, subnet2, securityGroup } from "./vpc"
|
||||
|
||||
// const taskExecutionRole = new aws.iam.Role('NestriRelayExecutionRole', {
|
||||
// assumeRolePolicy: JSON.stringify({
|
||||
// Version: '2012-10-17',
|
||||
// Statement: [
|
||||
// {
|
||||
// Effect: 'Allow',
|
||||
// Principal: {
|
||||
// Service: 'ecs-tasks.amazonaws.com',
|
||||
// },
|
||||
// Action: 'sts:AssumeRole',
|
||||
// },
|
||||
// ],
|
||||
// }),
|
||||
// });
|
||||
|
||||
// const taskRole = new aws.iam.Role('NestriRelayTaskRole', {
|
||||
// assumeRolePolicy: JSON.stringify({
|
||||
// Version: '2012-10-17',
|
||||
// Statement: [
|
||||
// {
|
||||
// Effect: 'Allow',
|
||||
// Principal: {
|
||||
// Service: 'ecs-tasks.amazonaws.com',
|
||||
// },
|
||||
// Action: 'sts:AssumeRole',
|
||||
// },
|
||||
// ],
|
||||
// }),
|
||||
// });
|
||||
|
||||
// new aws.cloudwatch.LogGroup('NestriRelayLogGroup', {
|
||||
// name: '/ecs/nestri-relay',
|
||||
// retentionInDays: 7,
|
||||
// });
|
||||
|
||||
// new aws.iam.RolePolicyAttachment('NestriRelayExecutionRoleAttachment', {
|
||||
// policyArn: 'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy',
|
||||
// role: taskRole,
|
||||
// });
|
||||
|
||||
// const logPolicy = new aws.iam.Policy('NestriRelayLogPolicy', {
|
||||
// policy: JSON.stringify({
|
||||
// Version: '2012-10-17',
|
||||
// Statement: [
|
||||
// {
|
||||
// Effect: 'Allow',
|
||||
// Action: ['logs:CreateLogStream', 'logs:PutLogEvents'],
|
||||
// Resource: 'arn:aws:logs:*:*:*',
|
||||
// },
|
||||
// ],
|
||||
// }),
|
||||
// });
|
||||
|
||||
// new aws.iam.RolePolicyAttachment('NestriRelayTaskRoleAttachment', {
|
||||
// policyArn: logPolicy.arn,
|
||||
// role: taskExecutionRole,
|
||||
// });
|
||||
|
||||
// const taskDefinition = new aws.ecs.TaskDefinition("NestriRelayTask", {
|
||||
// family: "NestriRelay",
|
||||
// cpu: "1024",
|
||||
// memory: "2048",
|
||||
// networkMode: "awsvpc",
|
||||
// taskRoleArn: taskRole.arn,
|
||||
// requiresCompatibilities: ["FARGATE"],
|
||||
// executionRoleArn: taskExecutionRole.arn,
|
||||
// containerDefinitions: JSON.stringify([{
|
||||
// name: "nestri-relay",
|
||||
// essential: true,
|
||||
// memory: 2048,
|
||||
// image: "ghcr.io/nestrilabs/nestri/relay:nightly",
|
||||
// portMappings: [
|
||||
// // HTTP port
|
||||
// {
|
||||
// protocol: "tcp",
|
||||
// hostPort: 80,
|
||||
// containerPort: 80,
|
||||
// },
|
||||
// // UDP port range (1,000 ports)
|
||||
// {
|
||||
// containerPortRange: "10000-11000",
|
||||
// protocol: "udp",
|
||||
// },
|
||||
// ],
|
||||
// "environment": [
|
||||
// {
|
||||
// name: "ENDPOINT_PORT",
|
||||
// value: "80"
|
||||
// },
|
||||
// ],
|
||||
// logConfiguration: {
|
||||
// logDriver: 'awslogs',
|
||||
// options: {
|
||||
// 'awslogs-group': '/ecs/nestri-relay',
|
||||
// 'awslogs-region': 'us-east-1',
|
||||
// 'awslogs-stream-prefix': 'ecs',
|
||||
// },
|
||||
// },
|
||||
// }]),
|
||||
// });
|
||||
|
||||
// const relayCluster = new aws.ecs.Cluster('NestriRelay');
|
||||
|
||||
// new aws.ecs.Service('NestriRelayService', {
|
||||
// name: 'NestriRelayService',
|
||||
// cluster: relayCluster.arn,
|
||||
// desiredCount: 1,
|
||||
// launchType: 'FARGATE',
|
||||
// taskDefinition: taskDefinition.arn,
|
||||
// deploymentCircuitBreaker: {
|
||||
// enable: true,
|
||||
// rollback: true,
|
||||
// },
|
||||
// enableExecuteCommand: true,
|
||||
// networkConfiguration: {
|
||||
// assignPublicIp: true,
|
||||
// subnets: [subnet1.id, subnet2.id],
|
||||
// securityGroups: [securityGroup.id],
|
||||
// },
|
||||
// });
|
||||
|
||||
//FIXME: I cannot create Global Accelerators (Something to do with Quotas - Yet my account is fine)
|
||||
// const usWest2 = new aws.Provider("GlobalAccelerator", { region: aws.Region.USWest2 })
|
||||
|
||||
// const accelerator = new aws.globalaccelerator.Accelerator('Accelerator', {
|
||||
// name: 'NestriRelayAccelerator',
|
||||
// enabled: true,
|
||||
// ipAddressType: 'IPV4',
|
||||
// }, { provider: usWest2 });
|
||||
|
||||
// const httpListener = new aws.globalaccelerator.Listener('TcpListener', {
|
||||
// acceleratorArn: accelerator.id,
|
||||
// clientAffinity: 'SOURCE_IP',
|
||||
// protocol: 'TCP',
|
||||
// portRanges: [{
|
||||
// fromPort: 80,
|
||||
// toPort: 80,
|
||||
// }],
|
||||
// }, { provider: usWest2 });
|
||||
|
||||
// const udpListener = new aws.globalaccelerator.Listener('UdpListener', {
|
||||
// acceleratorArn: accelerator.id,
|
||||
// clientAffinity: 'SOURCE_IP',
|
||||
// protocol: 'UDP',
|
||||
// portRanges: [{
|
||||
// fromPort: 10000,
|
||||
// toPort: 11000,
|
||||
// }],
|
||||
// }, { provider: usWest2 });
|
||||
|
||||
// new aws.globalaccelerator.EndpointGroup('TcpRelay', {
|
||||
// listenerArn: httpListener.id,
|
||||
// // healthCheckPath: '/',
|
||||
// endpointGroupRegion: aws.Region.USEast1,
|
||||
// endpointConfigurations: [{
|
||||
// clientIpPreservationEnabled: true,
|
||||
// endpointId: subnet1.id, //vpc.publicSubnets[0].apply(i => i),
|
||||
// weight: 100,
|
||||
// }],
|
||||
// }, { provider: usWest2 });
|
||||
|
||||
// new aws.globalaccelerator.EndpointGroup('UdpRelay', {
|
||||
// listenerArn: udpListener.id,
|
||||
// // healthCheckPort: 80,
|
||||
// // healthCheckPath: "/",
|
||||
// endpointGroupRegion: aws.Region.USEast1,
|
||||
// endpointConfigurations: [{
|
||||
// clientIpPreservationEnabled: true,
|
||||
// endpointId: subnet1.id,//vpc.publicSubnets[0].apply(i => i),
|
||||
// weight: 100,
|
||||
// }],
|
||||
// }, { provider: usWest2 });
|
||||
|
||||
// export const outputs = {
|
||||
// relay: accelerator.dnsName
|
||||
// }
|
||||
@@ -1,11 +0,0 @@
|
||||
export const secret = {
|
||||
// InstantAppId: new sst.Secret("InstantAppId"),
|
||||
PolarSecret: new sst.Secret("PolarSecret", process.env.POLAR_API_KEY),
|
||||
GithubClientID: new sst.Secret("GithubClientID"),
|
||||
DiscordClientID: new sst.Secret("DiscordClientID"),
|
||||
GithubClientSecret: new sst.Secret("GithubClientSecret"),
|
||||
// InstantAdminToken: new sst.Secret("InstantAdminToken"),
|
||||
DiscordClientSecret: new sst.Secret("DiscordClientSecret"),
|
||||
};
|
||||
|
||||
export const allSecrets = Object.values(secret);
|
||||
13
infra/secrets.ts
Normal file
13
infra/secrets.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const secret = {
|
||||
LoopsApiKey: new sst.Secret("LoopsApiKey"),
|
||||
InstantAppId: new sst.Secret("InstantAppId"),
|
||||
AwsSecretKey: new sst.Secret("AwsSecretKey"),
|
||||
AwsAccessKey: new sst.Secret("AwsAccessKey"),
|
||||
GithubClientID: new sst.Secret("GithubClientID"),
|
||||
DiscordClientID: new sst.Secret("DiscordClientID"),
|
||||
GithubClientSecret: new sst.Secret("GithubClientSecret"),
|
||||
InstantAdminToken: new sst.Secret("InstantAdminToken"),
|
||||
DiscordClientSecret: new sst.Secret("DiscordClientSecret"),
|
||||
};
|
||||
|
||||
export const allSecrets = Object.values(secret);
|
||||
19
infra/ssh.ts
Normal file
19
infra/ssh.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { resolve } from "path";
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
export const privateKey = new tls.PrivateKey("NestriGPUPrivateKey", {
|
||||
algorithm: "RSA",
|
||||
rsaBits: 4096,
|
||||
});
|
||||
|
||||
// Just in case you want to SSH
|
||||
export const sshKey = new aws.ec2.KeyPair("NestriGPUKey", {
|
||||
keyName: "NestriGPUKeyProd",
|
||||
publicKey: privateKey.publicKeyOpenssh
|
||||
})
|
||||
|
||||
export const keyPath = privateKey.privateKeyOpenssh.apply((key) => {
|
||||
const path = "key_ssh";
|
||||
writeFileSync(path, key, { mode: 0o600 });
|
||||
return resolve(path);
|
||||
});
|
||||
2
infra/stage.ts
Normal file
2
infra/stage.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const isPermanentStage =
|
||||
$app.stage === "production" || $app.stage === "dev";
|
||||
4
infra/storage.ts
Normal file
4
infra/storage.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// export const vpc = new sst.aws.Vpc("Vpc")
|
||||
|
||||
// export const storage = new sst.aws.Efs("GameStorage",{ vpc })
|
||||
// //
|
||||
103
infra/vpc.ts
Normal file
103
infra/vpc.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// export const vpc = new aws.ec2.Vpc('NestriVpc', {
|
||||
// cidrBlock: '172.16.0.0/16',
|
||||
// });
|
||||
|
||||
// export const subnet1 = new aws.ec2.Subnet('NestriSubnet1', {
|
||||
// vpcId: vpc.id,
|
||||
// cidrBlock: '172.16.1.0/24',
|
||||
// // cidrBlock: '110.0.12.0/22',
|
||||
// availabilityZone: 'us-east-1a',
|
||||
// });
|
||||
|
||||
// export const subnet2 = new aws.ec2.Subnet('NestriSubnet2', {
|
||||
// vpcId: vpc.id,
|
||||
// cidrBlock: '172.16.2.0/24',
|
||||
// // cidrBlock: '10.0.20.0/22',
|
||||
// availabilityZone: 'us-east-1b',
|
||||
// });
|
||||
|
||||
// const internetGateway = new aws.ec2.InternetGateway('NestriInternetGateway', {
|
||||
// vpcId: vpc.id,
|
||||
// });
|
||||
|
||||
// const routeTable = new aws.ec2.RouteTable('NestriRouteTable', {
|
||||
// vpcId: vpc.id,
|
||||
// routes: [
|
||||
// {
|
||||
// cidrBlock: '0.0.0.0/0',
|
||||
// gatewayId: internetGateway.id,
|
||||
// },
|
||||
// ],
|
||||
// });
|
||||
|
||||
// new aws.ec2.RouteTableAssociation('NestriSubnet1RouteTable', {
|
||||
// subnetId: subnet1.id,
|
||||
// routeTableId: routeTable.id,
|
||||
// });
|
||||
|
||||
// new aws.ec2.RouteTableAssociation('NestriSubnet2RouteTable', {
|
||||
// subnetId: subnet2.id,
|
||||
// routeTableId: routeTable.id,
|
||||
// });
|
||||
|
||||
// // const vpc = new sst.aws.Vpc("NestriRelayVpc")
|
||||
|
||||
// export const securityGroup = new aws.ec2.SecurityGroup("NestriSecurityGroup", {
|
||||
// vpcId: vpc.id,
|
||||
// description: "Managed thru SST",
|
||||
// ingress: [
|
||||
// {
|
||||
// protocol: "tcp",
|
||||
// fromPort: 80,
|
||||
// toPort: 80,
|
||||
// cidrBlocks: ["0.0.0.0/0"],
|
||||
// },
|
||||
// {
|
||||
// protocol: "udp",
|
||||
// fromPort: 10000,
|
||||
// toPort: 20000,
|
||||
// cidrBlocks: ["0.0.0.0/0"],
|
||||
// },
|
||||
// ],
|
||||
// egress: [
|
||||
// {
|
||||
// protocol: "-1",
|
||||
// cidrBlocks: ["0.0.0.0/0"],
|
||||
// fromPort: 0,
|
||||
// toPort: 0
|
||||
// }
|
||||
// ]
|
||||
// });
|
||||
|
||||
// const loadBalancer = new aws.lb.LoadBalancer('NestriVpcLoadBalancer', {
|
||||
// name: 'NestriVpcLoadBalancer',
|
||||
// internal: false,
|
||||
// securityGroups: [securityGroup.id],
|
||||
// subnets: vpc.publicSubnets
|
||||
// });
|
||||
|
||||
// const targetGroup = new aws.lb.TargetGroup('NestriVpcTargetGroup', {
|
||||
// name: 'NestriVpcTargetGroup',
|
||||
// port: 80,
|
||||
// protocol: 'HTTP',
|
||||
// targetType: 'ip',
|
||||
// vpcId: vpc.id,
|
||||
// healthCheck: {
|
||||
// path: '/',
|
||||
// protocol: 'HTTP',
|
||||
// },
|
||||
// });
|
||||
|
||||
// new aws.lb.Listener('NestriVpcLoadBalancerListener', {
|
||||
// loadBalancerArn: loadBalancer.arn,
|
||||
// port: 80,
|
||||
// protocol: 'HTTP',
|
||||
// defaultActions: [
|
||||
// {
|
||||
// type: 'forward',
|
||||
// targetGroupArn: targetGroup.arn,
|
||||
// },
|
||||
// ],
|
||||
// });
|
||||
|
||||
// // export const subnets = [subnet1, subnet2]
|
||||
20
infra/www.ts
20
infra/www.ts
@@ -1,20 +0,0 @@
|
||||
// This is the website part where people play and connect
|
||||
import { domain } from "./dns";
|
||||
import { auth, api } from "./api";
|
||||
|
||||
new sst.aws.StaticSite("Web", {
|
||||
path: "./packages/www",
|
||||
build: {
|
||||
output: "./dist",
|
||||
command: "bun run build",
|
||||
},
|
||||
domain: {
|
||||
dns: sst.cloudflare.dns(),
|
||||
name: "console." + domain
|
||||
},
|
||||
environment: {
|
||||
VITE_API_URL: api.url,
|
||||
VITE_AUTH_URL: auth.url,
|
||||
VITE_STAGE: $app.stage,
|
||||
},
|
||||
})
|
||||
27
package.json
27
package.json
@@ -1,31 +1,34 @@
|
||||
{
|
||||
"name": "nestri",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"dev": "turbo dev",
|
||||
"sst": "sst dev",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"lint": "turbo lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "4.20240821.1",
|
||||
"@pulumi/pulumi": "^3.134.0",
|
||||
"@types/aws-lambda": "8.10.147",
|
||||
"@types/aws-lambda": "8.10.145",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"packageManager": "bun@1.2.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"sso": "aws sso login --sso-session=nestri --no-browser --use-device-code"
|
||||
},
|
||||
"packageManager": "bun@1.1.18",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"trustedDependencies": [
|
||||
"core-js-pure",
|
||||
"esbuild",
|
||||
"workerd"
|
||||
],
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"sst": "3.9.1"
|
||||
"sst": "3.6.27"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Resource } from "sst";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
function addPoolerSuffix(original: string): string {
|
||||
const firstDotIndex = original.indexOf('.');
|
||||
if (firstDotIndex === -1) return original + '-pooler';
|
||||
return original.slice(0, firstDotIndex) + '-pooler' + original.slice(firstDotIndex);
|
||||
}
|
||||
|
||||
const dbHost = addPoolerSuffix(Resource.Database.host)
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/**/*.sql.ts",
|
||||
out: "./migrations",
|
||||
dialect: "postgresql",
|
||||
verbose: true,
|
||||
dbCredentials: {
|
||||
url: `postgresql://${Resource.Database.user}:${Resource.Database.password}@${dbHost}/${Resource.Database.name}?sslmode=require`,
|
||||
},
|
||||
});
|
||||
30
packages/core/instant.perms.ts
Normal file
30
packages/core/instant.perms.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Docs: https://www.instantdb.com/docs/permissions
|
||||
|
||||
import type { InstantRules } from "@instantdb/core";
|
||||
|
||||
const rules = {
|
||||
/**
|
||||
* Welcome to Instant's permission system!
|
||||
* Right now your rules are empty. To start filling them in, check out the docs:
|
||||
* https://www.instantdb.com/docs/permissions
|
||||
*
|
||||
* Here's an example to give you a feel:
|
||||
* posts: {
|
||||
* allow: {
|
||||
* view: "true",
|
||||
* create: "isOwner",
|
||||
* update: "isOwner",
|
||||
* delete: "isOwner",
|
||||
* },
|
||||
* bind: ["isOwner", "auth.id != null && auth.id == data.ownerId"],
|
||||
* },
|
||||
*/
|
||||
// $default: {
|
||||
// allow: {
|
||||
// $default: "isOwner"
|
||||
// },
|
||||
// bind: ["isOwner", "auth.id != null && auth.id == data.ownerID"],
|
||||
// }
|
||||
} satisfies InstantRules;
|
||||
|
||||
export default rules;
|
||||
123
packages/core/instant.schema.ts
Normal file
123
packages/core/instant.schema.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { i } from "@instantdb/core";
|
||||
|
||||
const _schema = i.schema({
|
||||
entities: {
|
||||
$users: i.entity({
|
||||
email: i.string().unique().indexed(),
|
||||
}),
|
||||
// machines: i.entity({
|
||||
// hostname: i.string(),
|
||||
// fingerprint: i.string().unique().indexed(),
|
||||
// deletedAt: i.date().optional().indexed(),
|
||||
// createdAt: i.date()
|
||||
// }),
|
||||
tasks: i.entity({
|
||||
type: i.string(),
|
||||
lastStatus: i.string(),
|
||||
healthStatus: i.string(),
|
||||
startedAt: i.string(),
|
||||
lastUpdated: i.date(),
|
||||
stoppedAt: i.string().optional(),
|
||||
taskID: i.string().unique().indexed()
|
||||
}),
|
||||
instances: i.entity({
|
||||
hostname: i.string(),
|
||||
lastActive: i.date().optional(),
|
||||
createdAt: i.date()
|
||||
}),
|
||||
profiles: i.entity({
|
||||
avatarUrl: i.string().optional(),
|
||||
username: i.string().indexed(),
|
||||
status: i.string().indexed(),
|
||||
updatedAt: i.date().indexed(),
|
||||
createdAt: i.date(),
|
||||
discriminator: i.string().indexed()
|
||||
}),
|
||||
teams: i.entity({
|
||||
name: i.string(),
|
||||
slug: i.string().unique().indexed(),
|
||||
deletedAt: i.date().optional(),//.indexed(),
|
||||
updatedAt: i.date(),
|
||||
createdAt: i.date(),
|
||||
}),
|
||||
// games: i.entity({
|
||||
// name: i.string(),
|
||||
// steamID: i.number().unique().indexed(),
|
||||
// }),
|
||||
sessions: i.entity({
|
||||
startedAt: i.date(),
|
||||
endedAt: i.date().optional().indexed(),
|
||||
public: i.boolean().indexed(),
|
||||
}),
|
||||
subscriptions: i.entity({
|
||||
checkoutID: i.string(),
|
||||
canceledAt: i.date(),
|
||||
})
|
||||
},
|
||||
links: {
|
||||
UserSubscriptions: {
|
||||
forward: { on: "subscriptions", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "subscriptions" }
|
||||
},
|
||||
UserProfiles: {
|
||||
forward: { on: "profiles", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "one", label: "profile" }
|
||||
},
|
||||
UserTasks: {
|
||||
forward: { on: "tasks", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "tasks" }
|
||||
},
|
||||
TaskSessions: {
|
||||
forward: { on: "tasks", has: "many", label: "sessions" },
|
||||
reverse: { on: "sessions", has: "one", label: "task" }
|
||||
},
|
||||
UserSession: {
|
||||
forward: { on: "sessions", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "sessions" }
|
||||
},
|
||||
TeamsOwned: {
|
||||
forward: { on: "teams", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "teamsOwned" },
|
||||
},
|
||||
TeamsJoined: {
|
||||
forward: { on: "teams", has: "many", label: "members" },
|
||||
reverse: { on: "$users", has: "many", label: "teamsJoined" },
|
||||
},
|
||||
// UserMachines: {
|
||||
// forward: { on: "machines", has: "one", label: "owner" },
|
||||
// reverse: { on: "$users", has: "many", label: "machines" }
|
||||
// },
|
||||
// UserGames: {
|
||||
// forward: { on: "games", has: "many", label: "owners" },
|
||||
// reverse: { on: "$users", has: "many", label: "games" }
|
||||
// },
|
||||
// TeamInstances: {
|
||||
// forward: { on: "instances", has: "many", label: "owners" },
|
||||
// reverse: { on: "teams", has: "many", label: "instances" }
|
||||
// },
|
||||
// MachineSessions: {
|
||||
// forward: { on: "machines", has: "many", label: "sessions" },
|
||||
// reverse: { on: "sessions", has: "one", label: "machine" }
|
||||
// },
|
||||
// GamesMachines: {
|
||||
// forward: { on: "machines", has: "many", label: "games" },
|
||||
// reverse: { on: "games", has: "many", label: "machines" }
|
||||
// },
|
||||
// GameSessions: {
|
||||
// forward: { on: "games", has: "many", label: "sessions" },
|
||||
// reverse: { on: "sessions", has: "one", label: "game" }
|
||||
// },
|
||||
// UserSessions: {
|
||||
// forward: { on: "sessions", has: "one", label: "owner" },
|
||||
// reverse: { on: "$users", has: "many", label: "sessions" }
|
||||
// }
|
||||
}
|
||||
});
|
||||
|
||||
// This helps Typescript display nicer intellisense
|
||||
type _AppSchema = typeof _schema;
|
||||
interface AppSchema extends _AppSchema { }
|
||||
const schema: AppSchema = _schema;
|
||||
|
||||
export type { AppSchema };
|
||||
export default schema;
|
||||
@@ -1,37 +0,0 @@
|
||||
CREATE TABLE "member" (
|
||||
"id" char(30) NOT NULL,
|
||||
"team_id" char(30) NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"time_seen" timestamp with time zone,
|
||||
"email" varchar(255) NOT NULL,
|
||||
CONSTRAINT "member_team_id_id_pk" PRIMARY KEY("team_id","id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "team" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"name" varchar(255) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"avatar_url" text,
|
||||
"email" varchar(255) NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"discriminator" integer NOT NULL,
|
||||
"polar_customer_id" varchar(255) NOT NULL,
|
||||
CONSTRAINT "user_polar_customer_id_unique" UNIQUE("polar_customer_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "member_email" ON "member" USING btree ("team_id","email");--> statement-breakpoint
|
||||
CREATE INDEX "email_global" ON "member" USING btree ("email");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "slug" ON "team" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "user_email" ON "user" USING btree ("email");
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "user" ALTER COLUMN "polar_customer_id" DROP NOT NULL;
|
||||
@@ -1,281 +0,0 @@
|
||||
{
|
||||
"id": "08ba0262-ce0a-4d87-b4e2-0d17dc0ee28c",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.member": {
|
||||
"name": "member",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"team_id": {
|
||||
"name": "team_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"time_seen": {
|
||||
"name": "time_seen",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"member_email": {
|
||||
"name": "member_email",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "team_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"email_global": {
|
||||
"name": "email_global",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"member_team_id_id_pk": {
|
||||
"name": "member_team_id_id_pk",
|
||||
"columns": [
|
||||
"team_id",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.team": {
|
||||
"name": "team",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"discriminator": {
|
||||
"name": "discriminator",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"polar_customer_id": {
|
||||
"name": "polar_customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email": {
|
||||
"name": "user_email",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_polar_customer_id_unique": {
|
||||
"name": "user_polar_customer_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"polar_customer_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
{
|
||||
"id": "c09359df-19fe-4246-9a41-43b3a429c12f",
|
||||
"prevId": "08ba0262-ce0a-4d87-b4e2-0d17dc0ee28c",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.member": {
|
||||
"name": "member",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"team_id": {
|
||||
"name": "team_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"time_seen": {
|
||||
"name": "time_seen",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"member_email": {
|
||||
"name": "member_email",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "team_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"email_global": {
|
||||
"name": "email_global",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"member_team_id_id_pk": {
|
||||
"name": "member_team_id_id_pk",
|
||||
"columns": [
|
||||
"team_id",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.team": {
|
||||
"name": "team",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"discriminator": {
|
||||
"name": "discriminator",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"polar_customer_id": {
|
||||
"name": "polar_customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email": {
|
||||
"name": "user_email",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_polar_customer_id_unique": {
|
||||
"name": "user_polar_customer_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"polar_customer_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1740345380808,
|
||||
"tag": "0000_wise_black_widow",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1740487217291,
|
||||
"tag": "0001_flaky_tomorrow_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,14 +3,6 @@
|
||||
"version": "0.0.0",
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"db": "sst shell drizzle-kit",
|
||||
"db:push": "sst shell drizzle-kit push",
|
||||
"db:migrate": "sst shell drizzle-kit migrate",
|
||||
"db:generate": "sst shell drizzle-kit generate",
|
||||
"db:connect": "sst shell ../scripts/src/psql.ts",
|
||||
"db:move": "sst shell drizzle-kit generate && sst shell drizzle-kit migrate && sst shell drizzle-kit push"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
@@ -18,7 +10,6 @@
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"loops": "^3.4.1",
|
||||
"mqtt": "^5.10.3",
|
||||
"remeda": "^2.19.0",
|
||||
@@ -28,13 +19,6 @@
|
||||
"zod-openapi": "^4.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sesv2": "^3.753.0",
|
||||
"@instantdb/admin": "^0.17.7",
|
||||
"@neondatabase/serverless": "^0.10.4",
|
||||
"@openauthjs/openauth": "0.4.3",
|
||||
"@openauthjs/openevent": "^0.0.27",
|
||||
"@polar-sh/sdk": "^0.26.1",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"ws": "^8.18.1"
|
||||
"@instantdb/admin": "^0.17.7"
|
||||
}
|
||||
}
|
||||
@@ -1,92 +1,86 @@
|
||||
import { z } from "zod";
|
||||
import { eq } from "./drizzle";
|
||||
import { VisibleError } from "./error";
|
||||
import { createContext } from "./context";
|
||||
import { UserFlags, userTable } from "./user/user.sql";
|
||||
import { useTransaction } from "./drizzle/transaction";
|
||||
import { VisibleError } from "./error";
|
||||
|
||||
export const PublicActor = z.object({
|
||||
type: z.literal("public"),
|
||||
properties: z.object({}),
|
||||
});
|
||||
export type PublicActor = z.infer<typeof PublicActor>;
|
||||
export interface UserActor {
|
||||
type: "user";
|
||||
properties: {
|
||||
accessToken: string;
|
||||
userID: string;
|
||||
auth?:
|
||||
| {
|
||||
type: "personal";
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
type: "oauth";
|
||||
clientID: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const UserActor = z.object({
|
||||
type: z.literal("user"),
|
||||
properties: z.object({
|
||||
userID: z.string(),
|
||||
email: z.string().nonempty(),
|
||||
}),
|
||||
});
|
||||
export type UserActor = z.infer<typeof UserActor>;
|
||||
export interface DeviceActor {
|
||||
type: "device";
|
||||
properties: {
|
||||
teamSlug: string;
|
||||
hostname: string;
|
||||
auth?:
|
||||
| {
|
||||
type: "personal";
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
type: "oauth";
|
||||
clientID: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const MemberActor = z.object({
|
||||
type: z.literal("member"),
|
||||
properties: z.object({
|
||||
memberID: z.string(),
|
||||
teamID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type MemberActor = z.infer<typeof MemberActor>;
|
||||
export interface PublicActor {
|
||||
type: "public";
|
||||
properties: {};
|
||||
}
|
||||
|
||||
export const SystemActor = z.object({
|
||||
type: z.literal("system"),
|
||||
properties: z.object({
|
||||
teamID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type SystemActor = z.infer<typeof SystemActor>;
|
||||
type Actor = UserActor | PublicActor | DeviceActor;
|
||||
export const ActorContext = createContext<Actor>();
|
||||
|
||||
export const Actor = z.discriminatedUnion("type", [
|
||||
MemberActor,
|
||||
UserActor,
|
||||
PublicActor,
|
||||
SystemActor,
|
||||
]);
|
||||
export type Actor = z.infer<typeof Actor>;
|
||||
|
||||
const ActorContext = createContext<Actor>("actor");
|
||||
|
||||
export const useActor = ActorContext.use;
|
||||
export const withActor = ActorContext.with;
|
||||
|
||||
export function useUserID() {
|
||||
export function useCurrentUser() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "user") return actor.properties.userID;
|
||||
if (actor.type === "user") return {
|
||||
id:actor.properties.userID,
|
||||
token: actor.properties.accessToken,
|
||||
};
|
||||
|
||||
throw new VisibleError(
|
||||
"auth",
|
||||
"unauthorized",
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
|
||||
export function useCurrentDevice() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "device") return {
|
||||
hostname:actor.properties.hostname,
|
||||
teamSlug: actor.properties.teamSlug
|
||||
};
|
||||
throw new VisibleError(
|
||||
"auth",
|
||||
"unauthorized",
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
|
||||
export function useActor() {
|
||||
try {
|
||||
return ActorContext.use();
|
||||
} catch {
|
||||
return { type: "public", properties: {} } as PublicActor;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertActor<T extends Actor["type"]>(type: T) {
|
||||
const actor = useActor();
|
||||
if (actor.type !== type) {
|
||||
throw new Error(`Expected actor type ${type}, got ${actor.type}`);
|
||||
}
|
||||
|
||||
if (actor.type !== type)
|
||||
throw new VisibleError("auth", "actor.invalid", `Actor is not "${type}"`);
|
||||
return actor as Extract<Actor, { type: T }>;
|
||||
}
|
||||
|
||||
export function useTeam() {
|
||||
const actor = useActor();
|
||||
if ("teamID" in actor.properties) return actor.properties.teamID;
|
||||
throw new Error(`Expected actor to have teamID`);
|
||||
}
|
||||
|
||||
export async function assertUserFlag(flag: keyof UserFlags) {
|
||||
return useTransaction((tx) =>
|
||||
tx
|
||||
.select({ flags: userTable.flags })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, useUserID()))
|
||||
.then((rows) => {
|
||||
const flags = rows[0]?.flags;
|
||||
if (!flags)
|
||||
throw new VisibleError(
|
||||
"user.flags",
|
||||
"Actor does not have " + flag + " flag",
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
90
packages/core/src/aws/client.ts
Normal file
90
packages/core/src/aws/client.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { z } from "zod"
|
||||
import { Resource } from "sst";
|
||||
import { doubleFn, fn } from "../utils";
|
||||
import { AwsClient } from "aws4fetch";
|
||||
import { DescribeTasksCommandOutput, StopTaskCommandOutput, type RunTaskCommandOutput } from "@aws-sdk/client-ecs";
|
||||
|
||||
|
||||
export module Aws {
|
||||
export const client = async () => {
|
||||
return new AwsClient({
|
||||
accessKeyId: Resource.AwsAccessKey.value,
|
||||
secretAccessKey: Resource.AwsSecretKey.value,
|
||||
region: "us-east-1",
|
||||
});
|
||||
}
|
||||
|
||||
export const EcsRunTask = fn(z.object({
|
||||
cluster: z.string(),
|
||||
count: z.number(),
|
||||
taskDefinition: z.string(),
|
||||
launchType: z.enum(["EC2", "FARGATE"]),
|
||||
overrides: z.object({
|
||||
containerOverrides: z.object({
|
||||
name: z.string(),
|
||||
environment: z.object({
|
||||
name: z.string(),
|
||||
value: z.string().or(z.number())
|
||||
}).array()
|
||||
}).array()
|
||||
})
|
||||
}), async (body) => {
|
||||
|
||||
const c = await client();
|
||||
|
||||
const url = new URL(`https://ecs.${c.region}.amazonaws.com/`)
|
||||
|
||||
const res = await c.fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Amz-Target": "AmazonEC2ContainerServiceV20141113.RunTask",
|
||||
"Content-Type": "application/x-amz-json-1.1",
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
return await res.json() as RunTaskCommandOutput
|
||||
})
|
||||
|
||||
export const EcsDescribeTasks = fn(z.object({ tasks: z.string().array(), cluster: z.string() }), async (body) => {
|
||||
const c = await client();
|
||||
|
||||
const url = new URL(`https://ecs.${c.region}.amazonaws.com/`)
|
||||
|
||||
const res = await c.fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Amz-Target": "AmazonEC2ContainerServiceV20141113.DescribeTasks",
|
||||
"Content-Type": "application/x-amz-json-1.1",
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
return await res.json() as DescribeTasksCommandOutput
|
||||
})
|
||||
|
||||
|
||||
export const EcsStopTask = fn(z.object({
|
||||
cluster: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
task: z.string()
|
||||
}), async (body) => {
|
||||
const c = await client();
|
||||
|
||||
const url = new URL(`https://ecs.${c.region}.amazonaws.com/`)
|
||||
|
||||
const res = await c.fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Amz-Target": "AmazonEC2ContainerServiceV20141113.StopTask",
|
||||
"Content-Type": "application/x-amz-json-1.1",
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
return await res.json() as StopTaskCommandOutput
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export function createContext<T>(name: string) {
|
||||
export function createContext<T>() {
|
||||
const storage = new AsyncLocalStorage<T>();
|
||||
return {
|
||||
use() {
|
||||
const result = storage.getStore();
|
||||
if (!result) {
|
||||
throw new Error("Context not provided: " + name);
|
||||
throw new Error("No context available");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
with<R>(value: T, fn: () => R) {
|
||||
return storage.run<R, any[]>(value, fn);
|
||||
return storage.run<R>(value, fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
12
packages/core/src/database.ts
Normal file
12
packages/core/src/database.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Resource } from "sst";
|
||||
import { init } from "@instantdb/admin";
|
||||
import schema from "../instant.schema";
|
||||
|
||||
const databaseClient = () => init({
|
||||
appId: Resource.InstantAppId.value,
|
||||
adminToken: Resource.InstantAdminToken.value,
|
||||
schema
|
||||
})
|
||||
|
||||
|
||||
export default databaseClient
|
||||
@@ -1,30 +0,0 @@
|
||||
export * from "drizzle-orm";
|
||||
import ws from 'ws';
|
||||
import { Resource } from "sst";
|
||||
import { drizzle as neonDrizzle, NeonDatabase } from "drizzle-orm/neon-serverless";
|
||||
// import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import { Pool, neonConfig } from "@neondatabase/serverless";
|
||||
|
||||
neonConfig.webSocketConstructor = ws;
|
||||
|
||||
function addPoolerSuffix(original: string): string {
|
||||
const firstDotIndex = original.indexOf('.');
|
||||
if (firstDotIndex === -1) return original + '-pooler';
|
||||
return original.slice(0, firstDotIndex) + '-pooler' + original.slice(firstDotIndex);
|
||||
}
|
||||
|
||||
const dbHost = addPoolerSuffix(Resource.Database.host)
|
||||
|
||||
const client = new Pool({ connectionString: `postgres://${Resource.Database.user}:${Resource.Database.password}@${dbHost}/${Resource.Database.name}?sslmode=require` })
|
||||
|
||||
export const db = neonDrizzle(client, {
|
||||
logger:
|
||||
process.env.DRIZZLE_LOG === "true"
|
||||
? {
|
||||
logQuery(query, params) {
|
||||
console.log("query", query);
|
||||
console.log("params", params);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { db } from ".";
|
||||
import {
|
||||
PgTransaction,
|
||||
PgTransactionConfig
|
||||
} from "drizzle-orm/pg-core";
|
||||
import {
|
||||
NeonQueryResultHKT
|
||||
// NeonHttpQueryResultHKT
|
||||
} from "drizzle-orm/neon-serverless";
|
||||
import { ExtractTablesWithRelations } from "drizzle-orm";
|
||||
import { createContext } from "../context";
|
||||
|
||||
export type Transaction = PgTransaction<
|
||||
NeonQueryResultHKT,
|
||||
Record<string, never>,
|
||||
ExtractTablesWithRelations<Record<string, never>>
|
||||
>;
|
||||
|
||||
type TxOrDb = Transaction | typeof db;
|
||||
|
||||
const TransactionContext = createContext<{
|
||||
tx: Transaction;
|
||||
effects: (() => void | Promise<void>)[];
|
||||
}>("TransactionContext");
|
||||
|
||||
export async function useTransaction<T>(callback: (trx: TxOrDb) => Promise<T>) {
|
||||
try {
|
||||
const { tx } = TransactionContext.use();
|
||||
return callback(tx);
|
||||
} catch {
|
||||
return callback(db);
|
||||
}
|
||||
}
|
||||
|
||||
export async function afterTx(effect: () => any | Promise<any>) {
|
||||
try {
|
||||
const { effects } = TransactionContext.use();
|
||||
effects.push(effect);
|
||||
} catch {
|
||||
await effect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTransaction<T>(
|
||||
callback: (tx: Transaction) => Promise<T>,
|
||||
isolationLevel?: PgTransactionConfig["isolationLevel"],
|
||||
): Promise<T> {
|
||||
try {
|
||||
const { tx } = TransactionContext.use();
|
||||
return callback(tx);
|
||||
} catch {
|
||||
const effects: (() => void | Promise<void>)[] = [];
|
||||
const result = await db.transaction(
|
||||
async (tx) => {
|
||||
return TransactionContext.with({ tx, effects }, () => callback(tx));
|
||||
},
|
||||
{
|
||||
isolationLevel: isolationLevel || "read committed",
|
||||
},
|
||||
);
|
||||
await Promise.all(effects.map((x) => x()));
|
||||
// await db.$client.end()
|
||||
return result as T;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
|
||||
export const ulid = (name: string) => char(name, { length: 26 + 4 });
|
||||
|
||||
export const id = {
|
||||
get id() {
|
||||
return ulid("id").primaryKey().notNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const teamID = {
|
||||
get id() {
|
||||
return ulid("id").notNull();
|
||||
},
|
||||
get teamID() {
|
||||
return ulid("team_id").notNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const utc = (name: string) =>
|
||||
rawTs(name, {
|
||||
withTimezone: true,
|
||||
// mode: "date"
|
||||
});
|
||||
|
||||
export const timestamps = {
|
||||
timeCreated: utc("time_created").notNull().defaultNow(),
|
||||
timeUpdated: utc("time_updated").notNull().defaultNow(),
|
||||
timeDeleted: utc("time_deleted"),
|
||||
};
|
||||
@@ -1,36 +1,45 @@
|
||||
import { Resource } from "sst";
|
||||
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
|
||||
|
||||
import { LoopsClient } from "loops";
|
||||
import { Resource } from "sst/resource"
|
||||
export namespace Email {
|
||||
export const Client = new SESv2Client({});
|
||||
export const Client = () => new LoopsClient(Resource.LoopsApiKey.value);
|
||||
|
||||
export async function send(
|
||||
from: string,
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
) {
|
||||
from = from + "@" + Resource.Mail.sender;
|
||||
console.log("sending email", subject, from, to);
|
||||
await Client.send(
|
||||
new SendEmailCommand({
|
||||
Destination: {
|
||||
ToAddresses: [to],
|
||||
},
|
||||
Content: {
|
||||
Simple: {
|
||||
Body: {
|
||||
Text: {
|
||||
Data: body,
|
||||
},
|
||||
},
|
||||
Subject: {
|
||||
Data: subject,
|
||||
},
|
||||
},
|
||||
},
|
||||
FromEmailAddress: `Nestri <${from}>`,
|
||||
}),
|
||||
|
||||
try {
|
||||
await Client().sendTransactionalEmail(
|
||||
{
|
||||
transactionalId: "cm58pdf8d03upb5ecirnmvrfb",
|
||||
email: to,
|
||||
dataVariables: {
|
||||
logincode: body
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error sending email", error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendWelcome(
|
||||
to: string,
|
||||
name: string,
|
||||
) {
|
||||
|
||||
try {
|
||||
await Client().sendTransactionalEmail(
|
||||
{
|
||||
transactionalId: "cm61jrbbx02twlstfwfcywt5u",
|
||||
email: to,
|
||||
dataVariables: {
|
||||
name
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error sending email", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export class VisibleError extends Error {
|
||||
constructor(
|
||||
public kind: "input" | "auth",
|
||||
public code: string,
|
||||
public message: string,
|
||||
) {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useActor } from "./actor";
|
||||
import { event as sstEvent } from "sst/event";
|
||||
import { ZodValidator } from "sst/event/validator";
|
||||
|
||||
export const createEvent = sstEvent.builder({
|
||||
validator: ZodValidator,
|
||||
metadata() {
|
||||
return {
|
||||
actor: useActor(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
import { openevent } from "@openauthjs/openevent/event";
|
||||
export { publish } from "@openauthjs/openevent/publisher/drizzle";
|
||||
|
||||
export const event = openevent({
|
||||
metadata() {
|
||||
return {
|
||||
actor: useActor(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,33 +1,75 @@
|
||||
import { prefixes } from "./utils";
|
||||
export module Examples {
|
||||
export const Id = (prefix: keyof typeof prefixes) =>
|
||||
`${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
|
||||
|
||||
export const User = {
|
||||
id: Id("user"),
|
||||
name: "John Doe",
|
||||
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
|
||||
email: "john@example.com",
|
||||
discriminator: 47,
|
||||
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
|
||||
polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
};
|
||||
|
||||
export const Task = {
|
||||
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
|
||||
taskID: "b8302fca2d224d91ab342a2e4ab926d3",
|
||||
type: "AWS" as const, //or "on-premises",
|
||||
lastStatus: "RUNNING" as const,
|
||||
healthStatus: "UNKNOWN" as const,
|
||||
startedAt: '2025-01-09T01:56:23.902Z',
|
||||
lastUpdated: '2025-01-09T01:56:23.902Z',
|
||||
stoppedAt: '2025-01-09T04:46:23.902Z'
|
||||
}
|
||||
|
||||
export const Profile = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
username: "janedoe47",
|
||||
status: "active" as const,
|
||||
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
|
||||
discriminator: 12, //it needs to be two digits
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
updatedAt: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Subscription = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
checkoutID: "0bfcb712-df43-4454-81a8-fbee66eddca4",
|
||||
// productID: "0bfcb712-df43-4454-81a8-fbee66eddca4",
|
||||
// quantity: 1,
|
||||
// frequency: "monthly" as const,
|
||||
// next: '2025-01-09T01:56:23.902Z',
|
||||
canceledAt: '2025-02-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Team = {
|
||||
id: Id("team"),
|
||||
name: "John Does' Team",
|
||||
slug: "john_doe",
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
// owner: true,
|
||||
name: "Jane Doe's Games",
|
||||
slug: "jane-does-games",
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
updatedAt: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Member = {
|
||||
id: Id("member"),
|
||||
email: "john@example.com",
|
||||
teamID: Id("team"),
|
||||
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
|
||||
export const Machine = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
hostname: "DESKTOP-EUO8VSF",
|
||||
fingerprint: "fc27f428f9ca47d4b41b70889ae0c62090",
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
deletedAt: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Polar = {
|
||||
teamID: Id("team"),
|
||||
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
|
||||
export const Instance = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
hostname: "a955e059f05d",
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
lastActive: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Game = {
|
||||
id: '0bfcb712-df13-4454-81a8-fbee66eddca4',
|
||||
name: "Control Ultimate Edition",
|
||||
steamID: 870780,
|
||||
}
|
||||
|
||||
export const Session = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
public: true,
|
||||
startedAt: '2025-01-04T11:56:23.902Z',
|
||||
endedAt: '2025-01-04T12:36:23.902Z'
|
||||
}
|
||||
}
|
||||
151
packages/core/src/game/index.ts
Normal file
151
packages/core/src/game/index.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
// import { z } from "zod"
|
||||
// import { fn } from "../utils";
|
||||
// import { Common } from "../common";
|
||||
// import { Examples } from "../examples";
|
||||
// import databaseClient from "../database"
|
||||
// import { id as createID } from "@instantdb/admin";
|
||||
// import { groupBy, map, pipe, values } from "remeda"
|
||||
// import { useCurrentDevice, useCurrentUser } from "../actor";
|
||||
|
||||
// export module Games {
|
||||
// export const Info = z
|
||||
// .object({
|
||||
// id: z.string().openapi({
|
||||
// description: Common.IdDescription,
|
||||
// example: Examples.Game.id,
|
||||
// }),
|
||||
// name: z.string().openapi({
|
||||
// description: "A human-readable name for the game, used for easy identification.",
|
||||
// example: Examples.Game.name,
|
||||
// }),
|
||||
// steamID: z.number().openapi({
|
||||
// description: "The Steam ID of the game, used to identify it during installation and runtime.",
|
||||
// example: Examples.Game.steamID,
|
||||
// })
|
||||
// })
|
||||
// .openapi({
|
||||
// ref: "Game",
|
||||
// description: "Represents a Steam game that can be installed and played on a machine.",
|
||||
// example: Examples.Game,
|
||||
// });
|
||||
|
||||
// export type Info = z.infer<typeof Info>;
|
||||
|
||||
// export const create = fn(Info.pick({ name: true, steamID: true }), async (input) => {
|
||||
// const id = createID()
|
||||
// const db = databaseClient()
|
||||
// const device = useCurrentDevice()
|
||||
|
||||
// await db.transact(
|
||||
// db.tx.games[id]!.update({
|
||||
// name: input.name,
|
||||
// steamID: input.steamID,
|
||||
// }).link({ machines: device.id })
|
||||
// )
|
||||
// //
|
||||
// return id
|
||||
// })
|
||||
|
||||
// export const list = async () => {
|
||||
// const db = databaseClient()
|
||||
// const user = useCurrentUser()
|
||||
|
||||
// const query = {
|
||||
// $users: {
|
||||
// $: { where: { id: user.id } },
|
||||
// games: {}
|
||||
// },
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
|
||||
// const games = res.$users[0]?.games
|
||||
// if (games && games.length > 0) {
|
||||
// const result = pipe(
|
||||
// games,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// name: group[0].name,
|
||||
// steamID: group[0].steamID,
|
||||
// }))
|
||||
// )
|
||||
// return result
|
||||
// }
|
||||
// return null
|
||||
// }
|
||||
|
||||
// export const fromSteamID = fn(z.number(), async (steamID) => {
|
||||
// const db = databaseClient()
|
||||
|
||||
// const query = {
|
||||
// games: {
|
||||
// $: {
|
||||
// where: {
|
||||
// steamID,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
|
||||
// const games = res.games
|
||||
|
||||
// if (games.length > 0) {
|
||||
// const result = pipe(
|
||||
// games,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// name: group[0].name,
|
||||
// steamID: group[0].steamID,
|
||||
// }))
|
||||
// )
|
||||
// return result[0]
|
||||
// }
|
||||
|
||||
// return null
|
||||
// })
|
||||
|
||||
// export const linkToCurrentUser = fn(z.string(), async (steamID) => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
|
||||
// await db.transact(db.tx.games[steamID]!.link({ owners: user.id }))
|
||||
|
||||
// return "ok"
|
||||
// })
|
||||
|
||||
// export const unLinkFromCurrentUser = fn(z.number(), async (steamID) => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
|
||||
// const query = {
|
||||
// $users: {
|
||||
// $: { where: { id: user.id } },
|
||||
// games: {
|
||||
// $: {
|
||||
// where: {
|
||||
// steamID,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
// const games = res.$users[0]?.games
|
||||
// if (games && games.length > 0) {
|
||||
// const game = games[0] as Info
|
||||
// await db.transact(db.tx.games[game.id]!.unlink({ owners: user.id }))
|
||||
|
||||
// return "ok"
|
||||
// }
|
||||
|
||||
// return null
|
||||
// })
|
||||
|
||||
// }
|
||||
83
packages/core/src/instance/index.ts
Normal file
83
packages/core/src/instance/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
|
||||
export module Instances {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Instance.id,
|
||||
}),
|
||||
hostname: z.string().openapi({
|
||||
description: "The container's hostname",
|
||||
example: Examples.Instance.hostname,
|
||||
}),
|
||||
createdAt: z.string().or(z.number()).openapi({
|
||||
description: "The time this instances was registered on the network",
|
||||
example: Examples.Instance.createdAt,
|
||||
}),
|
||||
lastActive: z.string().or(z.number()).optional().openapi({
|
||||
description: "The time this instance was last seen on the network",
|
||||
example: Examples.Instance.lastActive,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Instance",
|
||||
description: "Represents a running container that is connected to the Nestri network..",
|
||||
example: Examples.Instance,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
export const create = fn(z.object({ hostname: z.string(), teamID: z.string() }), async (input) => {
|
||||
const id = createID()
|
||||
const now = new Date().toISOString()
|
||||
const db = databaseClient()
|
||||
await db.transact(
|
||||
db.tx.instances[id]!.update({
|
||||
hostname: input.hostname,
|
||||
createdAt: now,
|
||||
}).link({ owners: input.teamID })
|
||||
)
|
||||
|
||||
return "ok"
|
||||
})
|
||||
|
||||
export const fromTeamID = fn(z.string(), async (teamID) => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
instances: {
|
||||
$: {
|
||||
where: {
|
||||
owners: teamID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const data = res.instances
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const result = pipe(
|
||||
data,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
lastActive: group[0].lastActive,
|
||||
hostname: group[0].hostname,
|
||||
createdAt: group[0].createdAt
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
}
|
||||
232
packages/core/src/machine/index.ts
Normal file
232
packages/core/src/machine/index.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
// import { z } from "zod"
|
||||
// import { fn } from "../utils";
|
||||
// import { Games } from "../game"
|
||||
// import { Common } from "../common";
|
||||
// import { Examples } from "../examples";
|
||||
// import { useCurrentUser } from "../actor";
|
||||
// import databaseClient from "../database"
|
||||
// import { id as createID } from "@instantdb/admin";
|
||||
// import { groupBy, map, pipe, values } from "remeda"
|
||||
// export module Machines {
|
||||
// export const Info = z
|
||||
// .object({
|
||||
// id: z.string().openapi({
|
||||
// description: Common.IdDescription,
|
||||
// example: Examples.Machine.id,
|
||||
// }),
|
||||
// hostname: z.string().openapi({
|
||||
// description: "The Linux hostname that identifies this machine",
|
||||
// example: Examples.Machine.hostname,
|
||||
// }),
|
||||
// fingerprint: z.string().openapi({
|
||||
// description: "A unique identifier derived from the machine's Linux machine ID.",
|
||||
// example: Examples.Machine.fingerprint,
|
||||
// }),
|
||||
// createdAt: z.string().or(z.number()).openapi({
|
||||
// description: "Represents a machine running on the Nestri network, containing its identifying information and metadata.",
|
||||
// example: Examples.Machine.createdAt,
|
||||
// })
|
||||
// })
|
||||
// .openapi({
|
||||
// ref: "Machine",
|
||||
// description: "Represents a physical or virtual machine connected to the Nestri network..",
|
||||
// example: Examples.Machine,
|
||||
// });
|
||||
|
||||
// export type Info = z.infer<typeof Info>;
|
||||
|
||||
// export const create = fn(Info.pick({ fingerprint: true, hostname: true }), async (input) => {
|
||||
// const id = createID()
|
||||
// const now = new Date().toISOString()
|
||||
// const db = databaseClient()
|
||||
// await db.transact(
|
||||
// db.tx.machines[id]!.update({
|
||||
// fingerprint: input.fingerprint,
|
||||
// hostname: input.hostname,
|
||||
// createdAt: now,
|
||||
// //Just in case it had been previously deleted
|
||||
// deletedAt: undefined
|
||||
// })
|
||||
// )
|
||||
|
||||
// return id
|
||||
// })
|
||||
|
||||
// // export const fromID = fn(z.string(), async (id) => {
|
||||
// const db = databaseClient()
|
||||
|
||||
// const query = {
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// id: id,
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
// const machines = res.machines
|
||||
|
||||
// if (machines && machines.length > 0) {
|
||||
// const result = pipe(
|
||||
// machines,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// fingerprint: group[0].fingerprint,
|
||||
// hostname: group[0].hostname,
|
||||
// createdAt: group[0].createdAt
|
||||
// }))
|
||||
// )
|
||||
// return result
|
||||
// }
|
||||
|
||||
// return null
|
||||
// })
|
||||
|
||||
// export const installedGames = fn(z.string(), async (id) => {
|
||||
// const db = databaseClient()
|
||||
|
||||
// const query = {
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// id: id,
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// },
|
||||
// games: {}
|
||||
// }
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
// const machines = res.machines
|
||||
|
||||
// if (machines && machines.length > 0) {
|
||||
// const games = machines[0]?.games as any
|
||||
// if (games.length > 0) {
|
||||
// return games as Games.Info[]
|
||||
// }
|
||||
// return null
|
||||
// }
|
||||
|
||||
// return null
|
||||
// })
|
||||
|
||||
// export const fromFingerprint = fn(z.string(), async (input) => {
|
||||
// const db = databaseClient()
|
||||
|
||||
// const query = {
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// fingerprint: input,
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
|
||||
// const machines = res.machines
|
||||
|
||||
// if (machines.length > 0) {
|
||||
// const result = pipe(
|
||||
// machines,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// fingerprint: group[0].fingerprint,
|
||||
// hostname: group[0].hostname,
|
||||
// createdAt: group[0].createdAt
|
||||
// }))
|
||||
// )
|
||||
// return result[0]
|
||||
// }
|
||||
|
||||
// return null
|
||||
// })
|
||||
|
||||
// export const list = async () => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
|
||||
// const query = {
|
||||
// $users: {
|
||||
// $: { where: { id: user.id } },
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
|
||||
// const machines = res.$users[0]?.machines
|
||||
// if (machines && machines.length > 0) {
|
||||
// const result = pipe(
|
||||
// machines,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// fingerprint: group[0].fingerprint,
|
||||
// hostname: group[0].hostname,
|
||||
// createdAt: group[0].createdAt
|
||||
// }))
|
||||
// )
|
||||
// return result
|
||||
// }
|
||||
// return null
|
||||
// }
|
||||
|
||||
// export const linkToCurrentUser = fn(z.string(), async (id) => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
|
||||
// await db.transact(db.tx.machines[id]!.link({ owner: user.id }))
|
||||
|
||||
// return "ok"
|
||||
// })
|
||||
|
||||
// export const unLinkFromCurrentUser = fn(z.string(), async (id) => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
// const now = new Date().toISOString()
|
||||
|
||||
// const query = {
|
||||
// $users: {
|
||||
// $: { where: { id: user.id } },
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// id,
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
// const machines = res.$users[0]?.machines
|
||||
// if (machines && machines.length > 0) {
|
||||
// const machine = machines[0] as Info
|
||||
// await db.transact(db.tx.machines[machine.id]!.update({ deletedAt: now }))
|
||||
|
||||
// return "ok"
|
||||
// }
|
||||
|
||||
// return null
|
||||
// })
|
||||
|
||||
// }
|
||||
@@ -1,133 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { useTeam } from "../actor";
|
||||
import { Common } from "../common";
|
||||
import { createID, fn } from "../utils";
|
||||
import { createEvent } from "../event";
|
||||
import { Examples } from "../examples";
|
||||
import { memberTable } from "./member.sql";
|
||||
import { and, eq, sql, asc, isNull } from "../drizzle";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export module Member {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Member.id,
|
||||
}),
|
||||
timeSeen: z.date().or(z.null()).openapi({
|
||||
description: "The last time this team member was active",
|
||||
example: Examples.Member.timeSeen
|
||||
}),
|
||||
teamID: z.string().openapi({
|
||||
description: "The unique id of the team this member is on",
|
||||
example: Examples.Member.teamID
|
||||
}),
|
||||
email: z.string().openapi({
|
||||
description: "The email of this team member",
|
||||
example: Examples.Member.email
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Member",
|
||||
description: "Represents a team member on Nestri",
|
||||
example: Examples.Member,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"member.created",
|
||||
z.object({
|
||||
memberID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
Updated: createEvent(
|
||||
"member.updated",
|
||||
z.object({
|
||||
memberID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ email: true, id: true })
|
||||
.partial({
|
||||
id: true,
|
||||
})
|
||||
.extend({
|
||||
first: z.boolean().optional(),
|
||||
}),
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("member");
|
||||
await tx.insert(memberTable).values({
|
||||
id,
|
||||
email: input.email,
|
||||
teamID: useTeam(),
|
||||
timeSeen: input.first ? sql`CURRENT_TIMESTAMP()` : null,
|
||||
}).onConflictDoUpdate({
|
||||
target: memberTable.id,
|
||||
set: {
|
||||
timeDeleted: null,
|
||||
}
|
||||
})
|
||||
await afterTx(() =>
|
||||
async () => bus.publish(Resource.Bus, Events.Created, { memberID: id }),
|
||||
);
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
export const remove = fn(Info.shape.id, (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(memberTable)
|
||||
.set({
|
||||
timeDeleted: sql`CURRENT_TIMESTAMP()`,
|
||||
})
|
||||
.where(and(eq(memberTable.id, input), eq(memberTable.teamID, useTeam())))
|
||||
.execute();
|
||||
return input;
|
||||
}),
|
||||
);
|
||||
|
||||
export const fromEmail = fn(z.string(), async (email) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(and(eq(memberTable.email, email), isNull(memberTable.timeDeleted)))
|
||||
.orderBy(asc(memberTable.timeCreated))
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.at(0))
|
||||
),
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(and(eq(memberTable.id, id), isNull(memberTable.timeDeleted)))
|
||||
.orderBy(asc(memberTable.timeCreated))
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.at(0))
|
||||
),
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof memberTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
email: input.email,
|
||||
teamID: input.teamID,
|
||||
timeSeen: input.timeSeen
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { teamIndexes } from "../team/team.sql";
|
||||
import { timestamps, utc, teamID } from "../drizzle/types";
|
||||
import { index, pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const memberTable = pgTable(
|
||||
"member",
|
||||
{
|
||||
...teamID,
|
||||
...timestamps,
|
||||
timeSeen: utc("time_seen"),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
...teamIndexes(table),
|
||||
uniqueIndex("member_email").on(table.teamID, table.email),
|
||||
index("email_global").on(table.email),
|
||||
],
|
||||
);
|
||||
@@ -1,169 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { eq, and } from "../drizzle";
|
||||
import { useTeam } from "../actor";
|
||||
import { createEvent } from "../event";
|
||||
import { polarTable, Standing } from "./polar.sql";
|
||||
import { Polar as PolarSdk } from "@polar-sh/sdk";
|
||||
import { useTransaction } from "../drizzle/transaction";
|
||||
|
||||
const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" });
|
||||
|
||||
export module Polar {
|
||||
export const client = polar;
|
||||
|
||||
export const Info = z.object({
|
||||
teamID: z.string(),
|
||||
customerID: z.string(),
|
||||
subscriptionID: z.string().nullable(),
|
||||
subscriptionItemID: z.string().nullable(),
|
||||
standing: z.enum(Standing),
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Checkout = z.object({
|
||||
annual: z.boolean().optional(),
|
||||
successUrl: z.string(),
|
||||
cancelUrl: z.string(),
|
||||
});
|
||||
|
||||
export const CheckoutSession = z.object({
|
||||
url: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const CustomerSubscriptionEventType = [
|
||||
"created",
|
||||
"updated",
|
||||
"deleted",
|
||||
] as const;
|
||||
|
||||
export const Events = {
|
||||
CustomerSubscriptionEvent: createEvent(
|
||||
"polar.customer-subscription-event",
|
||||
z.object({
|
||||
type: z.enum(CustomerSubscriptionEventType),
|
||||
status: z.string(),
|
||||
teamID: z.string().min(1),
|
||||
customerID: z.string().min(1),
|
||||
subscriptionID: z.string().min(1),
|
||||
subscriptionItemID: z.string().min(1),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export function get() {
|
||||
return useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(polarTable)
|
||||
.where(eq(polarTable.teamID, useTeam()))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0)),
|
||||
);
|
||||
}
|
||||
|
||||
export const fromUserEmail = fn(z.string().min(1), async (email) => {
|
||||
try {
|
||||
const customers = await client.customers.list({ email })
|
||||
|
||||
if (customers.result.items.length === 0) {
|
||||
return await client.customers.create({ email })
|
||||
} else {
|
||||
return customers.result.items[0]
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
//FIXME: This is the issue [Polar.sh/#5147](https://github.com/polarsource/polar/issues/5147)
|
||||
// console.log("error", err)
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
export const setCustomerID = fn(Info.shape.customerID, async (customerID) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.insert(polarTable)
|
||||
.values({
|
||||
teamID: useTeam(),
|
||||
customerID,
|
||||
standing: "new",
|
||||
})
|
||||
.execute(),
|
||||
),
|
||||
);
|
||||
|
||||
export const setSubscription = fn(
|
||||
Info.pick({
|
||||
subscriptionID: true,
|
||||
subscriptionItemID: true,
|
||||
}),
|
||||
(input) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.update(polarTable)
|
||||
.set({
|
||||
subscriptionID: input.subscriptionID,
|
||||
subscriptionItemID: input.subscriptionItemID,
|
||||
})
|
||||
.where(eq(polarTable.teamID, useTeam()))
|
||||
.returning()
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0)),
|
||||
),
|
||||
);
|
||||
|
||||
export const removeSubscription = fn(
|
||||
z.string().min(1),
|
||||
(stripeSubscriptionID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.update(polarTable)
|
||||
.set({
|
||||
subscriptionItemID: null,
|
||||
subscriptionID: null,
|
||||
})
|
||||
.where(and(eq(polarTable.subscriptionID, stripeSubscriptionID)))
|
||||
.execute(),
|
||||
),
|
||||
);
|
||||
|
||||
export const setStanding = fn(
|
||||
Info.pick({
|
||||
subscriptionID: true,
|
||||
standing: true,
|
||||
}),
|
||||
(input) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.update(polarTable)
|
||||
.set({ standing: input.standing })
|
||||
.where(and(eq(polarTable.subscriptionID, input.subscriptionID!)))
|
||||
.execute(),
|
||||
),
|
||||
);
|
||||
|
||||
export const fromCustomerID = fn(Info.shape.customerID, (customerID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(polarTable)
|
||||
.where(and(eq(polarTable.customerID, customerID)))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0)),
|
||||
),
|
||||
);
|
||||
|
||||
function serialize(
|
||||
input: typeof polarTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
teamID: input.teamID,
|
||||
customerID: input.customerID,
|
||||
subscriptionID: input.subscriptionID,
|
||||
subscriptionItemID: input.subscriptionItemID,
|
||||
standing: input.standing,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { timestamps, teamID } from "../drizzle/types";
|
||||
import { teamIndexes, teamTable } from "../team/team.sql";
|
||||
import { pgTable, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const Standing = ["new", "good", "overdue"] as const;
|
||||
|
||||
export const polarTable = pgTable(
|
||||
"polar",
|
||||
{
|
||||
teamID: teamID.teamID.primaryKey().references(() => teamTable.id),
|
||||
...timestamps,
|
||||
customerID: varchar("customer_id", { length: 255 }).notNull(),
|
||||
subscriptionID: varchar("subscription_id", { length: 255 }),
|
||||
subscriptionItemID: varchar("subscription_item_id", {
|
||||
length: 255,
|
||||
}),
|
||||
standing: text("standing", { enum: Standing }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
...teamIndexes(table),
|
||||
})
|
||||
)
|
||||
412
packages/core/src/profile/index.ts
Normal file
412
packages/core/src/profile/index.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { id as createID, } from "@instantdb/admin";
|
||||
import { useCurrentUser } from "../actor";
|
||||
|
||||
export const userStatus = z.enum([
|
||||
"active", //online and playing a game
|
||||
"idle", //online and not playing
|
||||
"offline",
|
||||
]);
|
||||
|
||||
export module Profiles {
|
||||
const MAX_ATTEMPTS = 50;
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Machine.id,
|
||||
}),
|
||||
username: z.string().openapi({
|
||||
description: "The user's unique username",
|
||||
example: Examples.Profile.username,
|
||||
}),
|
||||
avatarUrl: z.string().or(z.undefined()).openapi({
|
||||
description: "The url to the profile picture.",
|
||||
example: Examples.Profile.username,
|
||||
}),
|
||||
status: userStatus.openapi({
|
||||
description: "Whether the user is active, idle or offline",
|
||||
example: Examples.Profile.status
|
||||
}),
|
||||
discriminator: z.string().or(z.number()).openapi({
|
||||
description: "The number discriminator for each username",
|
||||
example: Examples.Profile.discriminator,
|
||||
}),
|
||||
createdAt: z.string().or(z.number()).openapi({
|
||||
description: "The time when this profile was first created",
|
||||
example: Examples.Profile.createdAt,
|
||||
}),
|
||||
updatedAt: z.string().or(z.number()).openapi({
|
||||
description: "The time when this profile was last edited",
|
||||
example: Examples.Profile.updatedAt,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Profile",
|
||||
description: "Represents a profile of a user on Nestri",
|
||||
example: Examples.Profile,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
export type userStatus = z.infer<typeof userStatus>;
|
||||
|
||||
export const sanitizeUsername = (username: string): string => {
|
||||
// Remove spaces and numbers
|
||||
return username.replace(/[\s0-9]/g, '');
|
||||
};
|
||||
|
||||
export const generateDiscriminator = (): string => {
|
||||
return Math.floor(Math.random() * 100).toString().padStart(2, '0');
|
||||
};
|
||||
|
||||
export const isValidDiscriminator = (discriminator: string): boolean => {
|
||||
return /^\d{2}$/.test(discriminator);
|
||||
};
|
||||
|
||||
export const fromUsername = fn(z.string(), async (input) => {
|
||||
const sanitizedUsername = sanitizeUsername(input);
|
||||
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
username: sanitizedUsername,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length == 0) {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
discriminator: group[0].discriminator,
|
||||
updatedAt: group[0].updatedAt,
|
||||
status: group[0].status as userStatus
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
export const findAvailableDiscriminator = fn(z.string(), async (input) => {
|
||||
const db = databaseClient()
|
||||
const username = sanitizeUsername(input);
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const discriminator = generateDiscriminator();
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
username,
|
||||
discriminator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
const profiles = res.profiles
|
||||
if (profiles.length === 0) {
|
||||
return discriminator;
|
||||
}
|
||||
}
|
||||
return null; // No available discriminators
|
||||
|
||||
})
|
||||
|
||||
export const create = fn(z.object({ username: z.string(), customDiscriminator: z.string().optional(), avatarUrl: z.string().optional(), owner: z.string() }), async (input) => {
|
||||
const username = sanitizeUsername(input.username);
|
||||
|
||||
const db = databaseClient()
|
||||
const id = createID()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
let discriminator: string | null;
|
||||
if (input.customDiscriminator) {
|
||||
if (!isValidDiscriminator(input.customDiscriminator)) {
|
||||
console.error('Invalid discriminator format')
|
||||
return null
|
||||
// throw new Error('Invalid discriminator format');
|
||||
}
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
username,
|
||||
discriminator: input.customDiscriminator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const profiles = res.profiles
|
||||
if (profiles.length != 0) {
|
||||
console.error("Username and discriminator combination already taken ")
|
||||
return null
|
||||
// throw new Error('Username and discriminator combination already taken');
|
||||
}
|
||||
|
||||
discriminator = input.customDiscriminator
|
||||
} else {
|
||||
// Generate a random available discriminator
|
||||
discriminator = await findAvailableDiscriminator(username);
|
||||
|
||||
if (!discriminator) {
|
||||
console.error("No available discriminators for this username ")
|
||||
return null
|
||||
// throw new Error('No available discriminators for this username');
|
||||
}
|
||||
}
|
||||
|
||||
return await db.transact(
|
||||
db.tx.profiles[id]!.update({
|
||||
username,
|
||||
avatarUrl: input.avatarUrl,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
discriminator,
|
||||
status: "idle"
|
||||
}).link({ owner: input.owner })
|
||||
)
|
||||
})
|
||||
|
||||
export const getFullUsername = async (username: string) => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
username,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
console.error('User not found')
|
||||
return null
|
||||
// throw new Error('User not found');
|
||||
}
|
||||
|
||||
return `${profiles[0]?.username}#${profiles[0]?.discriminator}`;
|
||||
}
|
||||
|
||||
export const fromOwnerID = async (ownerID: string) => {
|
||||
try {
|
||||
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
owner: ownerID
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
throw new Error("No profiles were found");
|
||||
}
|
||||
|
||||
const profile = pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
avatarUrl: group[0].avatarUrl,
|
||||
discriminator: group[0].discriminator,
|
||||
status: group[0].status as userStatus
|
||||
}))
|
||||
)
|
||||
|
||||
return profile[0]
|
||||
} catch (error) {
|
||||
console.log("user fromOwnerID", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fromID = async (id: string) => {
|
||||
try {
|
||||
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
id
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
throw new Error("No profiles were found");
|
||||
}
|
||||
|
||||
const profile = pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
avatarUrl: group[0].avatarUrl,
|
||||
discriminator: group[0].discriminator,
|
||||
status: group[0].status as userStatus
|
||||
}))
|
||||
)
|
||||
|
||||
return profile[0]
|
||||
} catch (error) {
|
||||
console.log("user fromID", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fromIDToOwner = async (id: string) => {
|
||||
try {
|
||||
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
id
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles as any
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
throw new Error("No profiles were found");
|
||||
}
|
||||
|
||||
return profiles[0]!.owner as string
|
||||
} catch (error) {
|
||||
console.log("user fromID", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
export const getCurrentProfile = async () => {
|
||||
const user = useCurrentUser()
|
||||
const currentProfile = await fromOwnerID(user.id);
|
||||
|
||||
return currentProfile
|
||||
}
|
||||
|
||||
export const setStatus = fn(userStatus, async (status) => {
|
||||
try {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.transact(
|
||||
db.tx.profiles[user.id]!.update({
|
||||
status,
|
||||
updatedAt: now
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.log("user setStatus error", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const list = async () => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
// const ago = new Date(Date.now() - (60 * 1000 * 30)).toISOString()
|
||||
const ago = new Date(Date.now() - (24 * 60 * 60 * 1000)).toISOString()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
limit: 10,
|
||||
where: {
|
||||
updatedAt: { $gt: ago },
|
||||
},
|
||||
order: {
|
||||
updatedAt: "desc" as const,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
throw new Error("No profiles were found");
|
||||
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
avatarUrl: group[0].avatarUrl,
|
||||
discriminator: group[0].discriminator,
|
||||
status: group[0].status as userStatus
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
} catch (error) {
|
||||
console.log("user list error", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
251
packages/core/src/session/index.ts
Normal file
251
packages/core/src/session/index.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
import { useCurrentUser } from "../actor";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
|
||||
export module Sessions {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Session.id,
|
||||
}),
|
||||
public: z.boolean().openapi({
|
||||
description: "If true, the session is publicly viewable by all users. If false, only authorized users can access it",
|
||||
example: Examples.Session.public,
|
||||
}),
|
||||
endedAt: z.string().or(z.number()).or(z.undefined()).openapi({
|
||||
description: "The timestamp indicating when this session was completed or terminated. Null if session is still active.",
|
||||
example: Examples.Session.endedAt,
|
||||
}),
|
||||
startedAt: z.string().or(z.number()).openapi({
|
||||
description: "The timestamp indicating when this session started.",
|
||||
example: Examples.Session.startedAt,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Session",
|
||||
description: "Represents a single game play session, tracking its lifetime and accessibility settings.",
|
||||
example: Examples.Session,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(z.object({ public: z.boolean() }), async (input) => {
|
||||
try {
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.transact(
|
||||
db.tx.sessions[id]!.update({
|
||||
public: input.public,
|
||||
startedAt: now,
|
||||
}).link({ owner: user.id })
|
||||
)
|
||||
|
||||
return id
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const getActive = async () => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
endedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const sessions = res.sessions
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No active sessions found")
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fromID = fn(z.string(), async (id) => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
id: id,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No sessions were found");
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
}))
|
||||
)
|
||||
return result
|
||||
} catch (err) {
|
||||
console.log("sessions error", err)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const fromTaskID = fn(z.string(), async (taskID) => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
task: taskID,
|
||||
endedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No sessions were found");
|
||||
}
|
||||
console.log("sessions", sessions)
|
||||
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
}))
|
||||
)
|
||||
return result[0]
|
||||
} catch (err) {
|
||||
console.log("sessions error", err)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const end = fn(z.string(), async (id) => {
|
||||
const user = useCurrentUser()
|
||||
try {
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
owner: user.id,
|
||||
id,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No sessions were found");
|
||||
}
|
||||
|
||||
await db.transact(db.tx.sessions[sessions[0]!.id]!.update({ endedAt: now }))
|
||||
|
||||
return "ok"
|
||||
|
||||
} catch (error) {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
export const fromOwnerID = fn(z.string(), async (id) => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
owner: id,
|
||||
endedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No sessions were found");
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
}))
|
||||
)
|
||||
return result[0]
|
||||
} catch (err) {
|
||||
console.log("session owner error", err)
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
205
packages/core/src/subscription/index.ts
Normal file
205
packages/core/src/subscription/index.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { z } from "zod";
|
||||
import databaseClient from "../database"
|
||||
import { fn } from "../utils";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { useCurrentUser } from "../actor";
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { Email } from "../email";
|
||||
import { Profiles } from "../profile";
|
||||
|
||||
export const SubscriptionFrequency = z.enum([
|
||||
"fixed",
|
||||
"daily",
|
||||
"weekly",
|
||||
"monthly",
|
||||
"yearly",
|
||||
]);
|
||||
|
||||
export type SubscriptionFrequency = z.infer<typeof SubscriptionFrequency>;
|
||||
|
||||
export namespace Subscriptions {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Subscription.id,
|
||||
}),
|
||||
checkoutID: z.string().openapi({
|
||||
description: "The polar.sh checkout id",
|
||||
example: Examples.Subscription.checkoutID,
|
||||
}),
|
||||
// productID: z.string().openapi({
|
||||
// description: "ID of the product being subscribed to.",
|
||||
// example: Examples.Subscription.productID,
|
||||
// }),
|
||||
// quantity: z.number().int().openapi({
|
||||
// description: "Quantity of the subscription.",
|
||||
// example: Examples.Subscription.quantity,
|
||||
// }),
|
||||
// frequency: SubscriptionFrequency.openapi({
|
||||
// description: "Frequency of the subscription.",
|
||||
// example: Examples.Subscription.frequency,
|
||||
// }),
|
||||
// next: z.string().or(z.number()).openapi({
|
||||
// description: "Next billing date for the subscription.",
|
||||
// example: Examples.Subscription.next,
|
||||
// }),
|
||||
canceledAt: z.string().or(z.number()).optional().openapi({
|
||||
description: "Cancelled date for the subscription.",
|
||||
example: Examples.Subscription.canceledAt,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Subscription",
|
||||
description: "Subscription to a Nestri product.",
|
||||
example: Examples.Subscription,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = fn(z.string().optional(), async (userID) => {
|
||||
const db = databaseClient()
|
||||
const user = userID ? userID : useCurrentUser().id
|
||||
|
||||
const query = {
|
||||
subscriptions: {
|
||||
$: {
|
||||
where: {
|
||||
owner: user,
|
||||
canceledAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const response = res.subscriptions
|
||||
if (!response || response.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
// next: group[0].next,
|
||||
// frequency: group[0].frequency as any,
|
||||
// quantity: group[0].quantity,
|
||||
// productID: group[0].productID,
|
||||
checkoutID: group[0].checkoutID,
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
export const create = fn(Info.omit({ id: true, canceledAt: true }), async (input) => {
|
||||
// const id = createID()
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
|
||||
//Use the polar.sh ID
|
||||
await db.transact(db.tx.subscriptions[id]!.update({
|
||||
// next: input.next,
|
||||
// frequency: input.frequency,
|
||||
// quantity: input.quantity,
|
||||
checkoutID: input.checkoutID,
|
||||
}).link({ owner: user.id }))
|
||||
const res = await db.auth.getUser({ id: user.id })
|
||||
const profile = await Profiles.fromOwnerID(user.id)
|
||||
if (profile) {
|
||||
await Email.sendWelcome(res.email, profile.username)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
export const remove = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
|
||||
await db.transact(db.tx.subscriptions[id]!.update({
|
||||
canceledAt: new Date().toISOString()
|
||||
}))
|
||||
})
|
||||
|
||||
export const fromID = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const query = {
|
||||
subscriptions: {
|
||||
$: {
|
||||
where: {
|
||||
id,
|
||||
//Make sure they can only get subscriptions they own
|
||||
owner: user.id,
|
||||
canceledAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const response = res.subscriptions
|
||||
if (!response || response.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
checkoutID: group[0].checkoutID,
|
||||
// next: group[0].next,
|
||||
// frequency: group[0].frequency as any,
|
||||
// quantity: group[0].quantity,
|
||||
// productID: group[0].productID,
|
||||
}))
|
||||
)
|
||||
|
||||
return result[0]
|
||||
})
|
||||
|
||||
export const fromCheckoutID = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const query = {
|
||||
subscriptions: {
|
||||
$: {
|
||||
where: {
|
||||
id,
|
||||
//Make sure they can only get subscriptions they own
|
||||
checkoutID: id,
|
||||
canceledAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const response = res.subscriptions
|
||||
if (!response || response.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
checkoutID: group[0].checkoutID,
|
||||
}))
|
||||
)
|
||||
|
||||
return result[0]
|
||||
})
|
||||
}
|
||||
331
packages/core/src/task/index.ts
Normal file
331
packages/core/src/task/index.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { Aws } from "../aws/client";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
import { useCurrentUser } from "../actor";
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { Sessions } from "../session";
|
||||
|
||||
export const lastStatus = z.enum([
|
||||
"RUNNING",
|
||||
"PENDING",
|
||||
"UNKNOWN",
|
||||
"STOPPED",
|
||||
]);
|
||||
|
||||
export const taskType = z.enum([
|
||||
"AWS",
|
||||
"ON_PREMISES",
|
||||
"UNKNOWN"
|
||||
]);
|
||||
|
||||
export const healthStatus = z.enum([
|
||||
"HEALTHY",
|
||||
"UNHEALTHY",
|
||||
"UNKNOWN",
|
||||
]);
|
||||
|
||||
export type taskType = z.infer<typeof taskType>;
|
||||
export type lastStatus = z.infer<typeof lastStatus>;
|
||||
export type healthStatus = z.infer<typeof healthStatus>;
|
||||
|
||||
export module Tasks {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Task.id,
|
||||
}),
|
||||
type: taskType.openapi({
|
||||
description: "Where this task is hosted on",
|
||||
example: Examples.Task.type,
|
||||
}),
|
||||
taskID: z.string().openapi({
|
||||
description: "The id of this task as seen on AWS",
|
||||
example: Examples.Task.taskID,
|
||||
}),
|
||||
startedAt: z.string().or(z.number()).openapi({
|
||||
description: "The time this task was started",
|
||||
example: Examples.Task.startedAt,
|
||||
}),
|
||||
lastUpdated: z.string().or(z.number()).openapi({
|
||||
description: "The time the information about this task was last updated",
|
||||
example: Examples.Task.lastUpdated,
|
||||
}),
|
||||
stoppedAt: z.string().or(z.number()).optional().openapi({
|
||||
description: "The time this task was stopped or quit",
|
||||
example: Examples.Task.lastUpdated,
|
||||
}),
|
||||
lastStatus: lastStatus.openapi({
|
||||
description: "The last registered status of this task",
|
||||
example: Examples.Task.lastStatus,
|
||||
}),
|
||||
healthStatus: healthStatus.openapi({
|
||||
description: "The health status of this task",
|
||||
example: Examples.Task.healthStatus,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Subscription",
|
||||
description: "Subscription to a Nestri product.",
|
||||
example: Examples.Task,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async () => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
|
||||
try {
|
||||
const query = {
|
||||
tasks: {
|
||||
$: {
|
||||
where: {
|
||||
stoppedAt: { $isNull: true },
|
||||
owner: user.id
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query(query)
|
||||
|
||||
const response = data.tasks
|
||||
if (!response || response.length === 0) {
|
||||
throw new Error("No task for this user were found");
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
taskID: group[0].taskID,
|
||||
type: group[0].type as taskType,
|
||||
lastStatus: group[0].lastStatus as lastStatus,
|
||||
healthStatus: group[0].healthStatus as healthStatus,
|
||||
startedAt: group[0].startedAt,
|
||||
stoppedAt: group[0].stoppedAt,
|
||||
lastUpdated: group[0].lastUpdated,
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const create = async () => {
|
||||
const user = useCurrentUser()
|
||||
|
||||
try {
|
||||
|
||||
//TODO: Use a simpler way to set the session ID
|
||||
// const sessionID = createID()
|
||||
|
||||
const sessionID = await Sessions.create({ public: true })
|
||||
if (!sessionID) throw new Error("No session id was given");
|
||||
|
||||
const run = await Aws.EcsRunTask({
|
||||
count: 1,
|
||||
cluster: Resource.NestriGPUCluster.value,
|
||||
taskDefinition: Resource.NestriGPUTask.value,
|
||||
launchType: "EC2",
|
||||
overrides: {
|
||||
containerOverrides: [
|
||||
{
|
||||
name: "nestri",
|
||||
environment: [
|
||||
{
|
||||
name: "NESTRI_ROOM",
|
||||
value: sessionID
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
if (!run.tasks || run.tasks.length === 0) {
|
||||
throw new Error(`No tasks were started`);
|
||||
}
|
||||
|
||||
// Extract task details
|
||||
const task = run.tasks[0];
|
||||
const taskArn = task?.taskArn!;
|
||||
const taskId = taskArn.split('/').pop()!; // Extract task ID from ARN
|
||||
const taskStatus = task?.lastStatus;
|
||||
const taskHealthStatus = task?.healthStatus;
|
||||
const startedAt = task?.startedAt!;
|
||||
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
await db.transact(db.tx.tasks[id]!.update({
|
||||
taskID: taskId,
|
||||
type: "AWS",
|
||||
healthStatus: taskHealthStatus ? taskHealthStatus.toString() : "UNKNOWN",
|
||||
startedAt: startedAt ? startedAt.toISOString() : now,
|
||||
lastStatus: taskStatus,
|
||||
lastUpdated: now,
|
||||
}).link({ owner: user.id, sessions: sessionID }))
|
||||
|
||||
return id
|
||||
} catch (e) {
|
||||
console.error("error", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fromID = fn(z.string(), async (taskID) => {
|
||||
const db = databaseClient()
|
||||
try {
|
||||
const query = {
|
||||
tasks: {
|
||||
$: {
|
||||
where: {
|
||||
id: taskID,
|
||||
stoppedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query(query)
|
||||
|
||||
const response = data.tasks
|
||||
if (!response || response.length === 0) {
|
||||
throw new Error("No task with the given id was found");
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
taskID: group[0].taskID,
|
||||
type: group[0].type as taskType,
|
||||
lastStatus: group[0].lastStatus as lastStatus,
|
||||
healthStatus: group[0].healthStatus as healthStatus,
|
||||
startedAt: group[0].startedAt,
|
||||
stoppedAt: group[0].stoppedAt,
|
||||
lastUpdated: group[0].lastUpdated,
|
||||
}))
|
||||
)
|
||||
|
||||
return result[0]
|
||||
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const update = fn(z.string(), async (taskID) => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
tasks: {
|
||||
$: {
|
||||
where: {
|
||||
id: taskID,
|
||||
stoppedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query(query)
|
||||
|
||||
const response = data.tasks
|
||||
if (!response || response.length === 0) {
|
||||
throw new Error("No task with the given taskID was found");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const describeResponse = await Aws.EcsDescribeTasks({
|
||||
tasks: [response[0]!.taskID],
|
||||
cluster: Resource.NestriGPUCluster.value
|
||||
})
|
||||
|
||||
if (!describeResponse.tasks || describeResponse.tasks.length === 0) {
|
||||
throw new Error("No tasks were found");
|
||||
}
|
||||
|
||||
const task = describeResponse.tasks[0]!
|
||||
|
||||
const updatedDb = {
|
||||
healthStatus: task.healthStatus ? task.healthStatus : "UNKNOWN",
|
||||
lastStatus: task.lastStatus ? task.lastStatus : "UNKNOWN",
|
||||
lastUpdated: now,
|
||||
}
|
||||
|
||||
await db.transact(db.tx.tasks[response[0]!.id]!.update({
|
||||
...updatedDb
|
||||
}))
|
||||
|
||||
const updatedRes = [{ ...response[0]!, ...updatedDb }]
|
||||
|
||||
const result = pipe(
|
||||
updatedRes,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
taskID: group[0].taskID,
|
||||
type: group[0].type as taskType,
|
||||
lastStatus: group[0].lastStatus as lastStatus,
|
||||
healthStatus: group[0].healthStatus as healthStatus,
|
||||
startedAt: group[0].startedAt,
|
||||
stoppedAt: group[0].stoppedAt,
|
||||
lastUpdated: group[0].lastUpdated,
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
} catch (error) {
|
||||
console.error("update error", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const stop = fn(z.object({ taskID: z.string(), id: z.string() }), async (input) => {
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
try {
|
||||
//TODO:Check whether they own this task first
|
||||
|
||||
const stopResponse = await Aws.EcsStopTask({
|
||||
task: input.taskID,
|
||||
cluster: Resource.NestriGPUCluster.value,
|
||||
reason: "Client requested a shutdown"
|
||||
})
|
||||
|
||||
if (!stopResponse.task) {
|
||||
throw new Error(`No task was stopped`);
|
||||
}
|
||||
|
||||
await db.transact(db.tx.tasks[input.id]!.update({
|
||||
stoppedAt: now,
|
||||
lastUpdated: now,
|
||||
lastStatus: "STOPPED",
|
||||
healthStatus: "UNKNOWN"
|
||||
}))
|
||||
|
||||
return "ok"
|
||||
|
||||
} catch (error) {
|
||||
console.error("stop error", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,153 +1,164 @@
|
||||
import { z } from "zod";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import databaseClient from "../database"
|
||||
import { fn } from "../utils";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { Common } from "../common";
|
||||
import { createID, fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { teamTable } from "./team.sql";
|
||||
import { createEvent } from "../event";
|
||||
import { assertActor, withActor } from "../actor";
|
||||
import { and, eq, sql } from "../drizzle";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { useCurrentUser } from "../actor";
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
|
||||
export module Team {
|
||||
export namespace Teams {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Team.id,
|
||||
}),
|
||||
slug: z.string().openapi({
|
||||
description: "The unique and url-friendly slug of this team",
|
||||
example: Examples.Team.slug
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The name of this team",
|
||||
example: Examples.Team.name
|
||||
description: "Name of the team",
|
||||
example: Examples.Team.name,
|
||||
}),
|
||||
createdAt: z.string().or(z.number()).openapi({
|
||||
description: "The time when this team was first created",
|
||||
example: Examples.Team.createdAt,
|
||||
}),
|
||||
updatedAt: z.string().or(z.number()).openapi({
|
||||
description: "The time when this team was last edited",
|
||||
example: Examples.Team.updatedAt,
|
||||
}),
|
||||
// owner: z.boolean().openapi({
|
||||
// description: "Whether this team is owned by this user",
|
||||
// example: Examples.Team.owner,
|
||||
// }),
|
||||
slug: z.string().openapi({
|
||||
description: "This is the unique name identifier for the team",
|
||||
example: Examples.Team.slug
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Team",
|
||||
description: "Represents a team on Nestri",
|
||||
description: "A group of users sharing the same machines for gaming.",
|
||||
example: Examples.Team,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"team.created",
|
||||
z.object({
|
||||
teamID: z.string().nonempty(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
export const list = async () => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
|
||||
export class TeamExistsError extends HTTPException {
|
||||
constructor(slug: string) {
|
||||
super(
|
||||
400,
|
||||
{ message: `There is already a team named "${slug}"`, }
|
||||
);
|
||||
const query = {
|
||||
teams: {
|
||||
$: {
|
||||
where: {
|
||||
members: user.id,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ slug: true, id: true, name: true }).partial({
|
||||
id: true,
|
||||
}), (input) => {
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("team");
|
||||
const result = await tx.insert(teamTable).values({
|
||||
id,
|
||||
slug: input.slug,
|
||||
name: input.name
|
||||
})
|
||||
.onConflictDoNothing({ target: teamTable.slug })
|
||||
const res = await db.query(query)
|
||||
|
||||
if (!result.rowCount) throw new TeamExistsError(input.slug);
|
||||
const teams = res.teams
|
||||
if (!teams || teams.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
await afterTx(() =>
|
||||
withActor({ type: "system", properties: { teamID: id } }, () =>
|
||||
bus.publish(Resource.Bus, Events.Created, {
|
||||
teamID: id,
|
||||
})
|
||||
),
|
||||
);
|
||||
return id;
|
||||
})
|
||||
})
|
||||
|
||||
export const remove = fn(Info.shape.id, (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
const account = assertActor("user");
|
||||
const row = await tx
|
||||
.select({
|
||||
teamID: memberTable.teamID,
|
||||
})
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.teamID, input),
|
||||
eq(memberTable.email, account.properties.email),
|
||||
),
|
||||
const result = pipe(
|
||||
teams,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
slug: group[0].slug,
|
||||
//@ts-expect-error
|
||||
owner: group[0].owner === user.id
|
||||
}))
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => rows.at(0));
|
||||
if (!row) return;
|
||||
await tx
|
||||
.update(teamTable)
|
||||
.set({
|
||||
timeDeleted: sql`now()`,
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
export const fromSlug = fn(z.string(), async (slug) => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
teams: {
|
||||
$: {
|
||||
where: {
|
||||
slug,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const teams = res.teams
|
||||
if (!teams || teams.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
teams,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
slug: group[0].slug,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
// owner: group[0].owner === user.id
|
||||
}))
|
||||
)
|
||||
|
||||
return result[0]
|
||||
})
|
||||
.where(eq(teamTable.id, row.teamID));
|
||||
}),
|
||||
);
|
||||
|
||||
export const list = fn(z.void(), () =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize)),
|
||||
),
|
||||
);
|
||||
export const create = fn(Info.pick({ name: true, slug: true }), async (input) => {
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
export const fromID = fn(z.string().min(1), async (id) =>
|
||||
useTransaction(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.where(eq(teamTable.id, id))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.at(0));
|
||||
}),
|
||||
);
|
||||
|
||||
export const fromSlug = fn(z.string().min(1), async (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.where(eq(teamTable.slug, input))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.at(0));
|
||||
}),
|
||||
);
|
||||
|
||||
export function serialize(
|
||||
input: typeof teamTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
await db.transact(db.tx.teams[id]!.update({
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
};
|
||||
}
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).link({ owner: user.id, members: user.id }))
|
||||
|
||||
return id
|
||||
})
|
||||
|
||||
export const remove = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.transact(db.tx.teams[id]!.update({
|
||||
deletedAt: now
|
||||
}))
|
||||
|
||||
return "ok"
|
||||
})
|
||||
|
||||
export const invite = fn(z.object({email:z.string(), id: z.string()}), async (input) => {
|
||||
//TODO:
|
||||
// const db = databaseClient()
|
||||
// const now = new Date().toISOString()
|
||||
|
||||
// await db.transact(db.tx.teams[id]!.update({
|
||||
// deletedAt: now
|
||||
// }))
|
||||
|
||||
return "ok"
|
||||
})
|
||||
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import {} from "drizzle-orm/postgres-js";
|
||||
import { timestamps, id } from "../drizzle/types";
|
||||
import {
|
||||
pgTable,
|
||||
primaryKey,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const teamTable = pgTable(
|
||||
"team",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
slug: varchar("slug", { length: 255 }).notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("slug").on(table.slug)],
|
||||
);
|
||||
|
||||
export function teamIndexes(table: any) {
|
||||
return [
|
||||
primaryKey({
|
||||
columns: [table.teamID, table.id],
|
||||
}),
|
||||
];
|
||||
}
|
||||
17
packages/core/src/types.ts
Normal file
17
packages/core/src/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface CloudflareCF {
|
||||
colo: string;
|
||||
continent: string;
|
||||
country: string,
|
||||
city: string;
|
||||
region: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
metroCode: string;
|
||||
postalCode: string;
|
||||
timezone: string;
|
||||
regionCode: number;
|
||||
}
|
||||
|
||||
export interface CFRequest extends Request {
|
||||
cf: CloudflareCF
|
||||
}
|
||||
@@ -1,217 +1,37 @@
|
||||
import { z } from "zod";
|
||||
import { Polar } from "../polar";
|
||||
import { Team } from "../team";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import databaseClient from "../database"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { createID, fn } from "../utils";
|
||||
import { userTable } from "./user.sql";
|
||||
import { createEvent } from "../event";
|
||||
import { Examples } from "../examples";
|
||||
import { Resource } from "sst/resource";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
import { assertActor, withActor } from "../actor";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { and, eq, isNull, asc, getTableColumns, sql } from "../drizzle";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
|
||||
export module User {
|
||||
const MAX_ATTEMPTS = 50;
|
||||
|
||||
export module Users {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The user's unique username",
|
||||
example: Examples.User.name,
|
||||
}),
|
||||
polarCustomerID: z.string().or(z.null()).openapi({
|
||||
description: "The polar customer id for this user",
|
||||
example: Examples.User.polarCustomerID,
|
||||
}),
|
||||
email: z.string().openapi({
|
||||
description: "The email address of this user",
|
||||
email: z.string().nullable().openapi({
|
||||
description: "Email address of the user.",
|
||||
example: Examples.User.email,
|
||||
}),
|
||||
avatarUrl: z.string().or(z.null()).openapi({
|
||||
description: "The url to the profile picture.",
|
||||
example: Examples.User.name,
|
||||
}),
|
||||
discriminator: z.string().or(z.number()).openapi({
|
||||
description: "The (number) discriminator for this user",
|
||||
example: Examples.User.discriminator,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "User",
|
||||
description: "Represents a user on Nestri",
|
||||
description: "A Nestri console user.",
|
||||
example: Examples.User,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"user.created",
|
||||
z.object({
|
||||
userID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
Updated: createEvent(
|
||||
"user.updated",
|
||||
z.object({
|
||||
userID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const sanitizeUsername = (username: string): string => {
|
||||
// Remove spaces and numbers
|
||||
return username.replace(/[\s0-9]/g, '');
|
||||
};
|
||||
|
||||
export const generateDiscriminator = (): string => {
|
||||
return Math.floor(Math.random() * 100).toString().padStart(2, '0');
|
||||
};
|
||||
|
||||
export const isValidDiscriminator = (discriminator: string): boolean => {
|
||||
return /^\d{2}$/.test(discriminator);
|
||||
};
|
||||
|
||||
export const findAvailableDiscriminator = fn(z.string(), async (input) => {
|
||||
const username = sanitizeUsername(input);
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const discriminator = generateDiscriminator();
|
||||
|
||||
const users = await useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(and(eq(userTable.name, username), eq(userTable.discriminator, Number(discriminator))))
|
||||
)
|
||||
|
||||
if (users.length === 0) {
|
||||
return discriminator;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
export const fromEmail = fn(z.string(), async (email) => {
|
||||
const db = databaseClient()
|
||||
const res = await db.auth.getUser({ email })
|
||||
return res
|
||||
})
|
||||
|
||||
export const create = fn(Info.omit({ polarCustomerID: true, discriminator: true }).partial({ avatarUrl: true, id: true }), async (input) => {
|
||||
const userID = createID("user")
|
||||
export const create = fn(z.string(), async (email) => {
|
||||
const db = databaseClient()
|
||||
const token = await db.auth.createToken(email)
|
||||
|
||||
//FIXME: Do this much later, as Polar.sh has so many inconsistencies for fuck's sake
|
||||
|
||||
const customer = await Polar.fromUserEmail(input.email)
|
||||
console.log("customer", customer)
|
||||
|
||||
const name = sanitizeUsername(input.name);
|
||||
|
||||
// Generate a random available discriminator
|
||||
const discriminator = await findAvailableDiscriminator(name);
|
||||
|
||||
if (!discriminator) {
|
||||
console.error("No available discriminators for this username ")
|
||||
return null
|
||||
}
|
||||
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? userID;
|
||||
await tx.insert(userTable).values({
|
||||
id,
|
||||
name: input.name,
|
||||
avatarUrl: input.avatarUrl,
|
||||
email: input.email,
|
||||
discriminator: Number(discriminator),
|
||||
polarCustomerID: customer?.id
|
||||
return token
|
||||
})
|
||||
await afterTx(() =>
|
||||
withActor({
|
||||
type: "user",
|
||||
properties: {
|
||||
userID: id,
|
||||
email: input.email
|
||||
},
|
||||
},
|
||||
async () => bus.publish(Resource.Bus, Events.Created, { userID: id }),
|
||||
)
|
||||
);
|
||||
})
|
||||
|
||||
return userID;
|
||||
})
|
||||
|
||||
export const fromEmail = fn(z.string(), async (email) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(and(eq(userTable.email, email), isNull(userTable.timeDeleted)))
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.at(0))
|
||||
),
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(and(eq(userTable.id, id), isNull(userTable.timeDeleted)))
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.at(0))
|
||||
),
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof userTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
avatarUrl: input.avatarUrl,
|
||||
discriminator: input.discriminator,
|
||||
polarCustomerID: input.polarCustomerID,
|
||||
};
|
||||
}
|
||||
|
||||
export const remove = fn(Info.shape.id, (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(userTable)
|
||||
.set({
|
||||
timeDeleted: sql`CURRENT_TIMESTAMP()`,
|
||||
})
|
||||
.where(and(eq(userTable.id, input)))
|
||||
.execute();
|
||||
return input;
|
||||
}),
|
||||
);
|
||||
|
||||
export function teams() {
|
||||
const actor = assertActor("user");
|
||||
return useTransaction((tx) =>
|
||||
tx
|
||||
.select(getTableColumns(teamTable))
|
||||
.from(teamTable)
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.email, actor.properties.email),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => rows.map(Team.serialize))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { id, timestamps } from "../drizzle/types";
|
||||
import { integer, pgTable, text, uniqueIndex, varchar,json } from "drizzle-orm/pg-core";
|
||||
|
||||
// Whether this user is part of the Nestri Team, comes with privileges
|
||||
export const UserFlags = z.object({
|
||||
team: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UserFlags = z.infer<typeof UserFlags>;
|
||||
|
||||
export const userTable = pgTable(
|
||||
"user",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
avatarUrl: text("avatar_url"),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
discriminator: integer("discriminator").notNull(),
|
||||
polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(),
|
||||
flags: json("flags").$type<UserFlags>().default({}),
|
||||
},
|
||||
(user) => [
|
||||
uniqueIndex("user_email").on(user.email),
|
||||
],
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ulid } from "ulid";
|
||||
|
||||
export const prefixes = {
|
||||
user: "usr",
|
||||
team: "tea",
|
||||
member: "mbr"
|
||||
} as const;
|
||||
|
||||
export function createID(prefix: keyof typeof prefixes): string {
|
||||
return [prefixes[prefix], ulid()].join("_");
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./fn"
|
||||
export * from "./id"
|
||||
@@ -14,7 +14,6 @@
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.4.3",
|
||||
"hono": "^4.6.15",
|
||||
"hono-openapi": "^0.3.1",
|
||||
"partysocket": "1.0.3"
|
||||
|
||||
121
packages/functions/src/adapter.ts
Normal file
121
packages/functions/src/adapter.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Context } from "hono"
|
||||
import type { Adapter } from "@openauthjs/openauth/adapter/adapter"
|
||||
import { generateUnbiasedDigits, timingSafeCompare } from "@openauthjs/openauth/random"
|
||||
|
||||
export type ApiAdapterState =
|
||||
| {
|
||||
type: "start"
|
||||
}
|
||||
| {
|
||||
type: "code"
|
||||
resend?: boolean
|
||||
code: string
|
||||
claims: Record<string, string>
|
||||
}
|
||||
|
||||
export type ApiAdapterError =
|
||||
| {
|
||||
type: "invalid_code"
|
||||
}
|
||||
| {
|
||||
type: "invalid_claim"
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function ApiAdapter<
|
||||
Claims extends Record<string, string> = Record<string, string>,
|
||||
>(config: {
|
||||
length?: number
|
||||
request: (
|
||||
req: Request,
|
||||
state: ApiAdapterState,
|
||||
body?: Claims,
|
||||
error?: ApiAdapterError,
|
||||
) => Promise<Response>
|
||||
sendCode: (claims: Claims, code: string) => Promise<void | ApiAdapterError>
|
||||
}) {
|
||||
const length = config.length || 6
|
||||
function generate() {
|
||||
return generateUnbiasedDigits(length)
|
||||
}
|
||||
|
||||
return {
|
||||
type: "api", // this is a miscellaneous name, for lack of a better one
|
||||
init(routes, ctx) {
|
||||
async function transition(
|
||||
c: Context,
|
||||
next: ApiAdapterState,
|
||||
claims?: Claims,
|
||||
err?: ApiAdapterError,
|
||||
) {
|
||||
await ctx.set<ApiAdapterState>(c, "adapter", 60 * 60 * 24, next)
|
||||
const resp = ctx.forward(
|
||||
c,
|
||||
await config.request(c.req.raw, next, claims, err),
|
||||
)
|
||||
return resp
|
||||
}
|
||||
routes.get("/authorize", async (c) => {
|
||||
const resp = await transition(c, {
|
||||
type: "start",
|
||||
})
|
||||
return resp
|
||||
})
|
||||
|
||||
routes.post("/authorize", async (c) => {
|
||||
const code = generate()
|
||||
const body = await c.req.json()
|
||||
const state = await ctx.get<ApiAdapterState>(c, "adapter")
|
||||
const action = body.action
|
||||
|
||||
if (action === "request" || action === "resend") {
|
||||
const claims = body.claims as Claims
|
||||
delete body.action
|
||||
const err = await config.sendCode(claims, code)
|
||||
if (err) return transition(c, { type: "start" }, claims, err)
|
||||
return transition(
|
||||
c,
|
||||
{
|
||||
type: "code",
|
||||
resend: action === "resend",
|
||||
claims,
|
||||
code,
|
||||
},
|
||||
claims,
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
body.action === "verify" &&
|
||||
state.type === "code"
|
||||
) {
|
||||
const body = await c.req.json()
|
||||
const compare = body.code
|
||||
if (
|
||||
!state.code ||
|
||||
!compare ||
|
||||
!timingSafeCompare(state.code, compare)
|
||||
) {
|
||||
return transition(
|
||||
c,
|
||||
{
|
||||
...state,
|
||||
resend: false,
|
||||
},
|
||||
body.claims,
|
||||
{ type: "invalid_code" },
|
||||
)
|
||||
}
|
||||
await ctx.unset(c, "adapter")
|
||||
return ctx.forward(
|
||||
c,
|
||||
await ctx.success(c, { claims: state.claims as Claims }),
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
} satisfies Adapter<{ claims: Claims }>
|
||||
}
|
||||
|
||||
export type ApiAdapterOptions = Parameters<typeof ApiAdapter>[0]
|
||||
@@ -1,64 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { notPublic } from "./auth";
|
||||
import { Result } from "../common";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { assertActor } from "@nestri/core/actor";
|
||||
|
||||
export module AccountApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Account"],
|
||||
summary: "Retrieve the current user's details",
|
||||
description: "Returns the user's account details, plus the teams they have joined",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({
|
||||
...User.Info.shape,
|
||||
teams: Team.Info.array(),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved account details"
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "This account does not exist",
|
||||
},
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const actor = assertActor("user");
|
||||
const [currentUser, teams] = await Promise.all([User.fromID(actor.properties.userID), User.teams()])
|
||||
|
||||
if (!currentUser) return c.json({ error: "This account does not exist; it may have been deleted" }, 404)
|
||||
|
||||
const { id, email, name, polarCustomerID, avatarUrl, discriminator } = currentUser
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
teams,
|
||||
avatarUrl,
|
||||
discriminator,
|
||||
polarCustomerID,
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Resource } from "sst";
|
||||
import { subjects } from "../subjects";
|
||||
import { type MiddlewareHandler } from "hono";
|
||||
// import { User } from "@nestri/core/user/index";
|
||||
import { VisibleError } from "@nestri/core/error";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { useActor, withActor } from "@nestri/core/actor";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
|
||||
const client = createClient({
|
||||
issuer: Resource.Urls.auth,
|
||||
clientID: "api",
|
||||
});
|
||||
|
||||
export const notPublic: MiddlewareHandler = async (c, next) => {
|
||||
const actor = useActor();
|
||||
if (actor.type === "public")
|
||||
throw new HTTPException(401, { message: "Unauthorized" });
|
||||
return next();
|
||||
};
|
||||
|
||||
export const auth: MiddlewareHandler = async (c, next) => {
|
||||
const authHeader =
|
||||
c.req.query("authorization") ?? c.req.header("authorization");
|
||||
if (!authHeader) return next();
|
||||
const match = authHeader.match(/^Bearer (.+)$/);
|
||||
if (!match) {
|
||||
throw new VisibleError(
|
||||
"auth.token",
|
||||
"Bearer token not found or improperly formatted",
|
||||
);
|
||||
}
|
||||
const bearerToken = match[1];
|
||||
let result = await client.verify(subjects, bearerToken!);
|
||||
if (result.err) {
|
||||
throw new HTTPException(401, {
|
||||
message: "Unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
if (result.subject.type === "user") {
|
||||
const teamID = c.req.header("x-nestri-team") //|| c.req.query("teamID");
|
||||
if (!teamID) return withActor(result.subject, next);
|
||||
// const email = result.subject.properties.email;
|
||||
return withActor(
|
||||
{
|
||||
type: "system",
|
||||
properties: {
|
||||
teamID,
|
||||
},
|
||||
},
|
||||
next
|
||||
// async () => {
|
||||
// const user = await User.fromEmail(email);
|
||||
// if (!user || user.length === 0) {
|
||||
// c.status(401);
|
||||
// return c.text("Unauthorized");
|
||||
// }
|
||||
// return withActor(
|
||||
// {
|
||||
// type: "member",
|
||||
// properties: { userID: user[0].id, workspaceID: user.workspaceID },
|
||||
// },
|
||||
// next,
|
||||
// );
|
||||
// },
|
||||
);
|
||||
}
|
||||
};
|
||||
264
packages/functions/src/api/game.ts
Normal file
264
packages/functions/src/api/game.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
// import { z } from "zod";
|
||||
// import { Hono } from "hono";
|
||||
// import { Result } from "../common";
|
||||
// import { describeRoute } from "hono-openapi";
|
||||
// import { Games } from "@nestri/core/game/index";
|
||||
// import { Examples } from "@nestri/core/examples";
|
||||
// import { validator, resolver } from "hono-openapi/zod";
|
||||
// import { Sessions } from "@nestri/core/session/index";
|
||||
|
||||
// export module GameApi {
|
||||
// export const route = new Hono()
|
||||
// .get(
|
||||
// "/",
|
||||
// //FIXME: Add a way to filter through query params
|
||||
// describeRoute({
|
||||
// tags: ["Game"],
|
||||
// summary: "Retrieve all games in the user's library",
|
||||
// description: "Returns a list of all (known) games associated with the authenticated user",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// // "application/json": {
|
||||
// schema: Result(
|
||||
// Games.Info.array().openapi({
|
||||
// description: "A list of games owned by the user",
|
||||
// example: [Examples.Game],
|
||||
// }),
|
||||
// ),
|
||||
// },
|
||||
// },
|
||||
// description: "Successfully retrieved the user's library of games",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "No games were found in the authenticated user's library",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// async (c) => {
|
||||
// const games = await Games.list();
|
||||
// if (!games) return c.json({ error: "No games exist in this user's library" }, 404);
|
||||
// return c.json({ data: games }, 200);
|
||||
// },
|
||||
// )
|
||||
// .get(
|
||||
// "/:steamID",
|
||||
// describeRoute({
|
||||
// tags: ["Game"],
|
||||
// summary: "Retrieve a game by its Steam ID",
|
||||
// description: "Fetches detailed metadata about a specific game using its Steam ID",
|
||||
// responses: {
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "No game found matching the provided Steam ID",
|
||||
// },
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(
|
||||
// Games.Info.openapi({
|
||||
// description: "Detailed metadata about the requested game",
|
||||
// example: Examples.Game,
|
||||
// }),
|
||||
// ),
|
||||
// },
|
||||
// },
|
||||
// description: "Successfully retrieved game metadata",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// steamID: Games.Info.shape.steamID.openapi({
|
||||
// description: "The unique Steam ID used to identify a game",
|
||||
// example: Examples.Game.steamID,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param");
|
||||
// const game = await Games.fromSteamID(params.steamID);
|
||||
// if (!game) return c.json({ error: "Game not found" }, 404);
|
||||
// return c.json({ data: game }, 200);
|
||||
// },
|
||||
// )
|
||||
// .post(
|
||||
// "/:steamID",
|
||||
// describeRoute({
|
||||
// tags: ["Game"],
|
||||
// summary: "Add a game to the user's library using its Steam ID",
|
||||
// description: "Adds a game to the currently authenticated user's library. Once added, the user can play the game and share their progress with others",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(z.literal("ok"))
|
||||
// },
|
||||
// },
|
||||
// description: "Game successfully added to user's library",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "No game was found matching the provided Steam ID",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// steamID: Games.Info.shape.steamID.openapi({
|
||||
// description: "The unique Steam ID of the game to be added to the current user's library",
|
||||
// example: Examples.Game.steamID,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param")
|
||||
// const game = await Games.fromSteamID(params.steamID)
|
||||
// if (!game) return c.json({ error: "Game not found" }, 404);
|
||||
// const res = await Games.linkToCurrentUser(game.id)
|
||||
// return c.json({ data: res }, 200);
|
||||
// },
|
||||
// )
|
||||
// .delete(
|
||||
// "/:steamID",
|
||||
// describeRoute({
|
||||
// tags: ["Game"],
|
||||
// summary: "Remove game from user's library",
|
||||
// description: "Removes a game from the authenticated user's library. The game remains in the system but will no longer be accessible to the user",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(z.literal("ok")),
|
||||
// },
|
||||
// },
|
||||
// description: "Game successfully removed from library",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "The game with the specified Steam ID was not found",
|
||||
// },
|
||||
// }
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// steamID: Games.Info.shape.steamID.openapi({
|
||||
// description: "The Steam ID of the game to be removed",
|
||||
// example: Examples.Game.steamID,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param");
|
||||
// const res = await Games.unLinkFromCurrentUser(params.steamID)
|
||||
// if (!res) return c.json({ error: "Game not found the library" }, 404);
|
||||
// return c.json({ data: res }, 200);
|
||||
// },
|
||||
// )
|
||||
// .put(
|
||||
// "/",
|
||||
// describeRoute({
|
||||
// tags: ["Game"],
|
||||
// summary: "Update game metadata",
|
||||
// description: "Updates the metadata about a specific game using its Steam ID",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(z.literal("ok")),
|
||||
// },
|
||||
// },
|
||||
// description: "Game successfully updated",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "The game with the specified Steam ID was not found",
|
||||
// },
|
||||
// }
|
||||
// }),
|
||||
// validator(
|
||||
// "json",
|
||||
// Games.Info.omit({ id: true }).openapi({
|
||||
// description: "Game information",
|
||||
// //@ts-expect-error
|
||||
// example: { ...Examples.Game, id: undefined }
|
||||
// })
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("json");
|
||||
// const res = await Games.create(params)
|
||||
// if (!res) return c.json({ error: "Something went seriously wrong" }, 404);
|
||||
// return c.json({ data: res }, 200);
|
||||
// },
|
||||
// )
|
||||
// .get(
|
||||
// "/:steamID/sessions",
|
||||
// describeRoute({
|
||||
// tags: ["Game"],
|
||||
// summary: "Retrieve game sessions by the associated game's Steam ID",
|
||||
// description: "Fetches active and public game sessions associated with a specific game using its Steam ID",
|
||||
// responses: {
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "This game does not have nay publicly active sessions",
|
||||
// },
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(
|
||||
// Sessions.Info.array().openapi({
|
||||
// description: "Publicly active sessions associated with the game",
|
||||
// example: [Examples.Session],
|
||||
// }),
|
||||
// ),
|
||||
// },
|
||||
// },
|
||||
// description: "Successfully retrieved game sessions associated with this game",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// steamID: Games.Info.shape.steamID.openapi({
|
||||
// description: "The unique Steam ID used to identify a game",
|
||||
// example: Examples.Game.steamID,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param");
|
||||
// const sessions = await Sessions.fromSteamID(params.steamID);
|
||||
// if (!sessions) return c.json({ error: "This game does not have any publicly active game sessions" }, 404);
|
||||
// return c.json({ data: sessions }, 200);
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
@@ -1,13 +1,79 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Hono } from "hono";
|
||||
import { auth } from "./auth";
|
||||
import { Resource } from "sst";
|
||||
import { ZodError } from "zod";
|
||||
import { UserApi } from "./user";
|
||||
import { TaskApi } from "./task";
|
||||
// import { GameApi } from "./game";
|
||||
// import { TeamApi } from "./team";
|
||||
import { logger } from "hono/logger";
|
||||
import { AccountApi } from "./account";
|
||||
import { subjects } from "../subjects";
|
||||
import { SessionApi } from "./session";
|
||||
// import { MachineApi } from "./machine";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
import { SubscriptionApi } from "./subscription";
|
||||
import { VisibleError } from "@nestri/core/error";
|
||||
import { ActorContext } from '@nestri/core/actor';
|
||||
import { Hono, type MiddlewareHandler } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { handle, streamHandle } from "hono/aws-lambda";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
|
||||
const auth: MiddlewareHandler = async (c, next) => {
|
||||
const client = createClient({
|
||||
clientID: "api",
|
||||
issuer: Resource.Urls.auth
|
||||
});
|
||||
|
||||
const authHeader =
|
||||
c.req.query("authorization") ?? c.req.header("authorization");
|
||||
if (authHeader) {
|
||||
const match = authHeader.match(/^Bearer (.+)$/);
|
||||
if (!match || !match[1]) {
|
||||
throw new VisibleError(
|
||||
"input",
|
||||
"auth.token",
|
||||
"Bearer token not found or improperly formatted",
|
||||
);
|
||||
}
|
||||
const bearerToken = match[1];
|
||||
|
||||
const result = await client.verify(subjects, bearerToken!);
|
||||
if (result.err)
|
||||
throw new VisibleError("input", "auth.invalid", "Invalid bearer token");
|
||||
if (result.subject.type === "user") {
|
||||
return ActorContext.with(
|
||||
{
|
||||
type: "user",
|
||||
properties: {
|
||||
userID: result.subject.properties.userID,
|
||||
accessToken: result.subject.properties.accessToken,
|
||||
auth: {
|
||||
type: "oauth",
|
||||
clientID: result.aud,
|
||||
},
|
||||
},
|
||||
},
|
||||
next,
|
||||
);
|
||||
} else if (result.subject.type === "device") {
|
||||
return ActorContext.with(
|
||||
{
|
||||
type: "device",
|
||||
properties: {
|
||||
hostname: result.subject.properties.hostname,
|
||||
teamSlug: result.subject.properties.teamSlug,
|
||||
auth: {
|
||||
type: "oauth",
|
||||
clientID: result.aud,
|
||||
},
|
||||
},
|
||||
},
|
||||
next,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return ActorContext.with({ type: "public", properties: {} }, next);
|
||||
};
|
||||
|
||||
|
||||
const app = new Hono();
|
||||
@@ -19,8 +85,14 @@ app
|
||||
.use(auth)
|
||||
|
||||
const routes = app
|
||||
.get("/", (c) => c.text("Hello World!"))
|
||||
.route("/account", AccountApi.route)
|
||||
.get("/", (c) => c.text("Hello there 👋🏾"))
|
||||
.route("/users", UserApi.route)
|
||||
.route("/tasks", TaskApi.route)
|
||||
// .route("/teams", TeamApi.route)
|
||||
// .route("/games", GameApi.route)
|
||||
.route("/sessions", SessionApi.route)
|
||||
// .route("/machines", MachineApi.route)
|
||||
.route("/subscriptions", SubscriptionApi.route)
|
||||
.onError((error, c) => {
|
||||
console.warn(error);
|
||||
if (error instanceof VisibleError) {
|
||||
@@ -29,7 +101,7 @@ const routes = app
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
400
|
||||
error.kind === "auth" ? 401 : 400,
|
||||
);
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
@@ -79,15 +151,9 @@ app.get(
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
},
|
||||
TeamID: {
|
||||
type: "apiKey",
|
||||
description:"The team ID to use for this query",
|
||||
in: "header",
|
||||
name: "x-nestri-team"
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ Bearer: [], TeamID:[] }],
|
||||
security: [{ Bearer: [] }],
|
||||
servers: [
|
||||
{ description: "Production", url: "https://api.nestri.io" },
|
||||
],
|
||||
@@ -96,4 +162,4 @@ app.get(
|
||||
);
|
||||
|
||||
export type Routes = typeof routes;
|
||||
export const handler = process.env.SST_DEV ? handle(app) : streamHandle(app);
|
||||
export default app
|
||||
176
packages/functions/src/api/machine.ts
Normal file
176
packages/functions/src/api/machine.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// import { z } from "zod";
|
||||
// import { Hono } from "hono";
|
||||
// import { Result } from "../common";
|
||||
// import { describeRoute } from "hono-openapi";
|
||||
// import { Examples } from "@nestri/core/examples";
|
||||
// import { validator, resolver } from "hono-openapi/zod";
|
||||
// import { Machines } from "@nestri/core/machine/index";
|
||||
// export module MachineApi {
|
||||
// export const route = new Hono()
|
||||
// .get(
|
||||
// "/",
|
||||
// //FIXME: Add a way to filter through query params
|
||||
// describeRoute({
|
||||
// tags: ["Machine"],
|
||||
// summary: "Retrieve all machines",
|
||||
// description: "Returns a list of all machines registered to the authenticated user in the Nestri network",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(
|
||||
// // Machines.Info.array().openapi({
|
||||
// description: "A list of machines associated with the user",
|
||||
// example: [Examples.Machine],
|
||||
// }),
|
||||
// ),
|
||||
// },
|
||||
// },
|
||||
// description: "Successfully retrieved the list of machines",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "No machines found for the authenticated user",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// async (c) => {
|
||||
// const machines = await Machines.list();
|
||||
// if (!machines) return c.json({ error: "No machines found for this user" }, 404);
|
||||
// return c.json({ data: machines }, 200);
|
||||
// },
|
||||
// )
|
||||
// .get(
|
||||
// "/:fingerprint",
|
||||
// describeRoute({
|
||||
// tags: ["Machine"],
|
||||
// summary: "Retrieve machine by fingerprint",
|
||||
// description: "Fetches detailed information about a specific machine using its unique fingerprint derived from the Linux machine ID",
|
||||
// responses: {
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "No machine found matching the provided fingerprint",
|
||||
// },
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(
|
||||
// Machines.Info.openapi({
|
||||
// description: "Detailed information about the requested machine",
|
||||
// example: Examples.Machine,
|
||||
// }),
|
||||
// ),
|
||||
// },
|
||||
// },
|
||||
// description: "Successfully retrieved machine information",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// fingerprint: Machines.Info.shape.fingerprint.openapi({
|
||||
// description: "The unique fingerprint used to identify the machine, derived from its Linux machine ID",
|
||||
// example: Examples.Machine.fingerprint,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param");
|
||||
// const machine = await Machines.fromFingerprint(params.fingerprint);
|
||||
// if (!machine) return c.json({ error: "Machine not found" }, 404);
|
||||
// return c.json({ data: machine }, 200);
|
||||
// },
|
||||
// )
|
||||
// .post(
|
||||
// "/:fingerprint",
|
||||
// describeRoute({
|
||||
// tags: ["Machine"],
|
||||
// summary: "Register a machine to an owner",
|
||||
// description: "Associates a machine with the currently authenticated user's account, enabling them to manage and control the machine",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(z.literal("ok"))
|
||||
// },
|
||||
// },
|
||||
// description: "Machine successfully registered to user's account",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "No machine found matching the provided fingerprint",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// fingerprint: Machines.Info.shape.fingerprint.openapi({
|
||||
// description: "The unique fingerprint of the machine to be registered, derived from its Linux machine ID",
|
||||
// example: Examples.Machine.fingerprint,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param")
|
||||
// const machine = await Machines.fromFingerprint(params.fingerprint)
|
||||
// if (!machine) return c.json({ error: "Machine not found" }, 404);
|
||||
// const res = await Machines.linkToCurrentUser(machine.id)
|
||||
// return c.json({ data: res }, 200);
|
||||
// },
|
||||
// )
|
||||
// .delete(
|
||||
// "/:fingerprint",
|
||||
// describeRoute({
|
||||
// tags: ["Machine"],
|
||||
// summary: "Unregister machine from user",
|
||||
// description: "Removes the association between a machine and the authenticated user's account. This does not delete the machine itself, but removes the user's ability to manage it",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(z.literal("ok")),
|
||||
// },
|
||||
// },
|
||||
// description: "Machine successfully unregistered from user's account",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "The machine with the specified fingerprint was not found",
|
||||
// },
|
||||
// }
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// fingerprint: Machines.Info.shape.fingerprint.openapi({
|
||||
// description: "The unique fingerprint of the machine to be unregistered, derived from its Linux machine ID",
|
||||
// example: Examples.Machine.fingerprint,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param");
|
||||
// const res = await Machines.unLinkFromCurrentUser(params.fingerprint)
|
||||
// if (!res) return c.json({ error: "Machine not found for this user" }, 404);
|
||||
// return c.json({ data: res }, 200);
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
175
packages/functions/src/api/session.ts
Normal file
175
packages/functions/src/api/session.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Result } from "../common";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
import { Sessions } from "@nestri/core/session/index";
|
||||
|
||||
export module SessionApi {
|
||||
export const route = new Hono()
|
||||
.get(
|
||||
"/active",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Retrieve all active gaming sessions",
|
||||
description: "Returns a list of all active gaming sessions associated with the authenticated user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Sessions.Info.array().openapi({
|
||||
description: "A list of active gaming sessions associated with the user",
|
||||
example: [{ ...Examples.Session, public: true, endedAt: undefined }],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the list of active gaming sessions",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No active gaming sessions found for the authenticated user",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const res = await Sessions.getActive();
|
||||
if (!res) return c.json({ error: "No active gaming sessions found for this user" }, 404);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Retrieve a gaming session by id",
|
||||
description: "Fetches detailed information about a specific gaming session using its unique id",
|
||||
responses: {
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No gaming session found matching the provided id",
|
||||
},
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Sessions.Info.openapi({
|
||||
description: "Detailed information about the requested gaming session",
|
||||
example: Examples.Session,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved gaming session information",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Sessions.Info.shape.id.openapi({
|
||||
description: "The unique id used to identify the gaming session",
|
||||
example: Examples.Session.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const res = await Sessions.fromID(params.id);
|
||||
if (!res) return c.json({ error: "Session not found" }, 404);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Create a new gaming session for this user",
|
||||
description: "Create a new gaming session for the currently authenticated user, enabling them to play a game",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok"))
|
||||
},
|
||||
},
|
||||
description: "Gaming session successfully created",
|
||||
},
|
||||
422: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "Something went wrong while creating a gaming session for this user",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
public: Sessions.Info.shape.public.openapi({
|
||||
description: "Whether the session is publicly viewable by all users. If false, only authorized users can access it",
|
||||
example: Examples.Session.public
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("json")
|
||||
const session = await Sessions.create(params)
|
||||
if (!session) return c.json({ error: "Something went wrong while creating a session" }, 422);
|
||||
return c.json({ data: session }, 200);
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Terminate a gaming session",
|
||||
description: "This endpoint allows a user to terminate an active gaming session by providing the session's unique ID",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok")),
|
||||
},
|
||||
},
|
||||
description: "The session was successfully terminated.",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "The session with the specified ID could not be found by this user",
|
||||
},
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Sessions.Info.shape.id.openapi({
|
||||
description: "The unique identifier of the gaming session to be terminated. ",
|
||||
example: Examples.Session.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const res = await Sessions.end(params.id)
|
||||
if (!res) return c.json({ error: "Session is not owned by this user" }, 404);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
);
|
||||
}
|
||||
130
packages/functions/src/api/subscription.ts
Normal file
130
packages/functions/src/api/subscription.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Result } from "../common";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
import { Subscriptions } from "@nestri/core/subscription/index";
|
||||
export module SubscriptionApi {
|
||||
export const route = new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
tags: ["Subscription"],
|
||||
summary: "List subscriptions",
|
||||
description: "List the subscriptions associated with the current user.",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Subscriptions.Info.array().openapi({
|
||||
description: "List of subscriptions.",
|
||||
example: [Examples.Subscription],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "List of subscriptions.",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No subscriptions found for this user",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const data = await Subscriptions.list(undefined);
|
||||
if (!data) return c.json({ error: "No subscriptions found for this user" }, 404);
|
||||
return c.json({ data }, 200);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
tags: ["Subscription"],
|
||||
summary: "Subscribe",
|
||||
description: "Create a subscription for the current user.",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok")),
|
||||
},
|
||||
},
|
||||
description: "Subscription was created successfully.",
|
||||
},
|
||||
400: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "Subscription already exists.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
checkoutID: Subscriptions.Info.shape.id.openapi({
|
||||
description: "The checkout id information.",
|
||||
example: Examples.Subscription.id,
|
||||
})
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const data = await Subscriptions.fromCheckoutID(body.checkoutID)
|
||||
if (data) return c.json({ error: "Subscription already exists" })
|
||||
await Subscriptions.create(body);
|
||||
return c.json({ data: "ok" as const }, 200);
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
tags: ["Subscription"],
|
||||
summary: "Cancel",
|
||||
description: "Cancel a subscription for the current user.",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok")),
|
||||
},
|
||||
},
|
||||
description: "Subscription was cancelled successfully.",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "Subscription not found.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Subscriptions.Info.shape.id.openapi({
|
||||
description: "ID of the subscription to cancel.",
|
||||
example: Examples.Subscription.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const subscription = await Subscriptions.fromID(param.id);
|
||||
if (!subscription) return c.json({ error: "Subscription not found" }, 404);
|
||||
await Subscriptions.remove(param.id);
|
||||
return c.json({ data: "ok" as const }, 200);
|
||||
},
|
||||
);
|
||||
}
|
||||
277
packages/functions/src/api/task.ts
Normal file
277
packages/functions/src/api/task.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Result } from "../common";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Tasks } from "@nestri/core/task/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
import { useCurrentUser } from "@nestri/core/actor";
|
||||
import { Subscriptions } from "@nestri/core/subscription/index";
|
||||
import { Sessions } from "@nestri/core/session/index";
|
||||
|
||||
export module TaskApi {
|
||||
export const route = new Hono()
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Task"],
|
||||
summary: "List Tasks",
|
||||
description: "List all tasks by this user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Tasks.Info.openapi({
|
||||
description: "A task example gotten from this task id",
|
||||
examples: [Examples.Task],
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "Tasks owned by this user were found",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No tasks for this user were not found.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const task = await Tasks.list();
|
||||
if (!task) return c.json({ error: "No tasks were found for this user" }, 404);
|
||||
return c.json({ data: task }, 200);
|
||||
},
|
||||
)
|
||||
.get("/:id",
|
||||
describeRoute({
|
||||
tags: ["Task"],
|
||||
summary: "Get Task",
|
||||
description: "Get a task by its id",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Tasks.Info.openapi({
|
||||
description: "A task example gotten from this task id",
|
||||
example: Examples.Task,
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "A task with this id was found",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "A task with this id was not found.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Tasks.Info.shape.id.openapi({
|
||||
description: "ID of the task to get",
|
||||
example: Examples.Task.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const task = await Tasks.fromID(param.id);
|
||||
if (!task) return c.json({ error: "Task was not found" }, 404);
|
||||
return c.json({ data: task }, 200);
|
||||
},
|
||||
)
|
||||
.get("/:id/session",
|
||||
describeRoute({
|
||||
tags: ["Task"],
|
||||
summary: "Get the current session running on this task",
|
||||
description: "Get a task by its id",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Sessions.Info.openapi({
|
||||
description: "A session running on this task",
|
||||
example: Examples.Session,
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "A task with this id was found",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "A task with this id was not found.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Tasks.Info.shape.id.openapi({
|
||||
description: "ID of the task to get session information about",
|
||||
example: Examples.Task.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const task = await Tasks.fromID(param.id);
|
||||
if (!task) return c.json({ error: "Task was not found" }, 404);
|
||||
const session = await Sessions.fromTaskID(task.id)
|
||||
if (!session) return c.json({ error: "No session was found running on this task" }, 404);
|
||||
return c.json({ data: session }, 200);
|
||||
},
|
||||
)
|
||||
.delete("/:id",
|
||||
describeRoute({
|
||||
tags: ["Task"],
|
||||
summary: "Stop Task",
|
||||
description: "Stop a running task by its id",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok"))
|
||||
},
|
||||
},
|
||||
description: "A task with this id was found",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "A task with this id was not found.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Tasks.Info.shape.id.openapi({
|
||||
description: "The id of the task to get",
|
||||
example: Examples.Task.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const task = await Tasks.fromID(param.id);
|
||||
if (!task) return c.json({ error: "Task was not found" }, 404);
|
||||
|
||||
//End any running tasks then (and only then) kill the task
|
||||
const session = await Sessions.fromTaskID(task.id)
|
||||
if (session) { await Sessions.end(session.id) }
|
||||
|
||||
const res = await Tasks.stop({ taskID: task.taskID, id: param.id })
|
||||
if (!res) return c.json({ error: "Something went wrong trying to stop the task" }, 404);
|
||||
return c.json({ data: "ok" }, 200);
|
||||
},
|
||||
)
|
||||
.post("/",
|
||||
describeRoute({
|
||||
tags: ["Task"],
|
||||
summary: "Create Task",
|
||||
description: "Create a task",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(Tasks.Info.shape.id.openapi({
|
||||
description: "The id of the task created",
|
||||
example: Examples.Task.id,
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "A task with this id was created",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "A task with this id could not be created",
|
||||
},
|
||||
401: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "You are not authorised to do this",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const user = useCurrentUser();
|
||||
// const data = await Subscriptions.list(undefined);
|
||||
// if (!data) return c.json({ error: "You need a subscription to create a task" }, 404);
|
||||
if (user) {
|
||||
const task = await Tasks.create();
|
||||
if (!task) return c.json({ error: "Task could not be created" }, 404);
|
||||
return c.json({ data: task }, 200);
|
||||
}
|
||||
|
||||
return c.json({ error: "You are not authorized to do this" }, 401);
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
tags: ["Task"],
|
||||
summary: "Get an update on a task",
|
||||
description: "Updates the metadata about a task by querying remote task",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(Tasks.Info.openapi({
|
||||
description: "The updated information about this task",
|
||||
example: Examples.Task
|
||||
})),
|
||||
},
|
||||
},
|
||||
description: "Task successfully updated",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "The task specified id was not found",
|
||||
},
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Tasks.Info.shape.id.openapi({
|
||||
description: "The id of the task to update on",
|
||||
example: Examples.Task.id
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const res = await Tasks.update(params.id)
|
||||
if (!res) return c.json({ error: "Something went seriously wrong" }, 404);
|
||||
return c.json({ data: res[0] }, 200);
|
||||
},
|
||||
)
|
||||
}
|
||||
238
packages/functions/src/api/team.ts
Normal file
238
packages/functions/src/api/team.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Result } from "../common";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Teams } from "@nestri/core/team/index";
|
||||
import { Users } from "@nestri/core/user/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
|
||||
export module TeamApi {
|
||||
export const route = new Hono()
|
||||
.get(
|
||||
"/",
|
||||
//FIXME: Add a way to filter through query params
|
||||
describeRoute({
|
||||
tags: ["Team"],
|
||||
summary: "Retrieve all teams",
|
||||
description: "Returns a list of all teams which the authenticated user is part of",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Teams.Info.array().openapi({
|
||||
description: "A list of teams associated with the user",
|
||||
example: [Examples.Team],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the list teams",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No teams found for the authenticated user",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const teams = await Teams.list();
|
||||
if (!teams) return c.json({ error: "No teams found for this user" }, 404);
|
||||
return c.json({ data: teams }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:slug",
|
||||
describeRoute({
|
||||
tags: ["Team"],
|
||||
summary: "Retrieve a team by slug",
|
||||
description: "Fetch detailed information about a specific team using its unique slug",
|
||||
responses: {
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No team found matching the provided slug",
|
||||
},
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Teams.Info.openapi({
|
||||
description: "Detailed information about the requested team",
|
||||
example: Examples.Team,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the team information",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
slug: Teams.Info.shape.slug.openapi({
|
||||
description: "The unique slug used to identify the team",
|
||||
example: Examples.Team.slug,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const team = await Teams.fromSlug(params.slug);
|
||||
if (!team) return c.json({ error: "Team not found" }, 404);
|
||||
return c.json({ data: team }, 200);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
tags: ["Team"],
|
||||
summary: "Create a team",
|
||||
description: "Create a new team for the currently authenticated user, enabling them to invite and play a game together with friends",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok"))
|
||||
},
|
||||
},
|
||||
description: "Team successfully created",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "A team with this slug already exists",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
slug: Teams.Info.shape.slug.openapi({
|
||||
description: "The unique name to be used with this team",
|
||||
example: Examples.Team.slug
|
||||
}),
|
||||
name: Teams.Info.shape.name.openapi({
|
||||
description: "The human readable name to give this team",
|
||||
example: Examples.Team.name
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("json")
|
||||
const team = await Teams.fromSlug(params.slug)
|
||||
if (team) return c.json({ error: "A team with this slug already exists" }, 404);
|
||||
const res = await Teams.create(params)
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:slug",
|
||||
describeRoute({
|
||||
tags: ["Team"],
|
||||
summary: "Delete a team",
|
||||
description: "This endpoint allows a user to delete a team, by providing it's unique slug",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok")),
|
||||
},
|
||||
},
|
||||
description: "The team was successfully deleted.",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "A team with this slug does not exist",
|
||||
},
|
||||
401: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "Your are not authorized to delete this team",
|
||||
},
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
slug: Teams.Info.shape.slug.openapi({
|
||||
description: "The unique slug of the team to be deleted. ",
|
||||
example: Examples.Team.slug,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const team = await Teams.fromSlug(params.slug)
|
||||
if (!team) return c.json({ error: "Team not found" }, 404);
|
||||
// if (!team.owner) return c.json({ error: "Your are not authorised to delete this team" }, 401)
|
||||
const res = await Teams.remove(team.id);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:slug/invite/:email",
|
||||
describeRoute({
|
||||
tags: ["Team"],
|
||||
summary: "Invite a user to a team",
|
||||
description: "Invite a user to a team owned by the current user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok")),
|
||||
},
|
||||
},
|
||||
description: "User successfully invited",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "The game with the specified Steam ID was not found",
|
||||
},
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
slug: Teams.Info.shape.slug.openapi({
|
||||
description: "The unique slug of the team the user wants to invite ",
|
||||
example: Examples.Team.slug,
|
||||
}),
|
||||
email: Users.Info.shape.email.openapi({
|
||||
description: "The email of the user to invite",
|
||||
example: Examples.User.email
|
||||
})
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const team = await Teams.fromSlug(params.slug)
|
||||
if (!team) return c.json({ error: "Team not found" }, 404);
|
||||
// if (!team.owner) return c.json({ error: "Your are not authorized to delete this team" }, 401)
|
||||
return c.json({ data: "ok" }, 200);
|
||||
},
|
||||
)
|
||||
}
|
||||
177
packages/functions/src/api/user.ts
Normal file
177
packages/functions/src/api/user.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Result } from "../common";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Profiles } from "@nestri/core/profile/index";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
import { Sessions } from "@nestri/core/session/index";
|
||||
|
||||
export module UserApi {
|
||||
export const route = new Hono()
|
||||
.get(
|
||||
"/@me",
|
||||
describeRoute({
|
||||
tags: ["User"],
|
||||
summary: "Retrieve current user's profile",
|
||||
description: "Returns the current authenticate user's profile",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Profiles.Info.openapi({
|
||||
description: "The profile for this user",
|
||||
example: Examples.Profile,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the user's profile",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No user profile found",
|
||||
},
|
||||
},
|
||||
}), async (c) => {
|
||||
const profile = await Profiles.getCurrentProfile();
|
||||
if (!profile) return c.json({ error: "No profile found for this user" }, 404);
|
||||
return c.json({ data: profile }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
tags: ["User"],
|
||||
summary: "List all user profiles",
|
||||
description: "Returns all user profiles",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Profiles.Info.openapi({
|
||||
description: "The profiles of all users",
|
||||
examples: [Examples.Profile],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved all user profiles",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No user profiles were found",
|
||||
},
|
||||
},
|
||||
}), async (c) => {
|
||||
const profiles = await Profiles.list();
|
||||
if (!profiles) return c.json({ error: "No user profiles were found" }, 404);
|
||||
return c.json({ data: profiles }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
tags: ["User"],
|
||||
summary: "Retrieve a user's profile",
|
||||
description: "Gets a user's profile by their id",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Profiles.Info.openapi({
|
||||
description: "The profile of the users",
|
||||
example: Examples.Profile,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the user profile",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No user profile was found",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Profiles.Info.shape.id.openapi({
|
||||
description: "ID of the user profile to get",
|
||||
example: Examples.Profile.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
console.log("id", param.id)
|
||||
const profiles = await Profiles.fromID(param.id);
|
||||
if (!profiles) return c.json({ error: "No user profile was found" }, 404);
|
||||
return c.json({ data: profiles }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:id/session",
|
||||
describeRoute({
|
||||
tags: ["User"],
|
||||
summary: "Retrieve a user's active session",
|
||||
description: "Get a user's active gaming session details by their id",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Sessions.Info.openapi({
|
||||
description: "The active session of this user",
|
||||
example: Examples.Session,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the active user gaming session",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No active gaming session for this user",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Sessions.Info.shape.id.openapi({
|
||||
description: "ID of the user's gaming session to get",
|
||||
example: Examples.Session.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const ownerID = await Profiles.fromIDToOwner(param.id);
|
||||
if (!ownerID) return c.json({ error: "We could not get the owner of this profile" }, 404);
|
||||
const session = await Sessions.fromOwnerID(ownerID)
|
||||
if(!session) return c.json({ error: "This user profile does not have active sessions" }, 404);
|
||||
return c.json({ data: session }, 200);
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,40 @@
|
||||
import { Resource } from "sst"
|
||||
import {
|
||||
type ExecutionContext,
|
||||
type KVNamespace,
|
||||
} from "@cloudflare/workers-types"
|
||||
import { Select } from "./ui/select";
|
||||
import { subjects } from "./subjects"
|
||||
import { logger } from "hono/logger";
|
||||
import { handle } from "hono/aws-lambda";
|
||||
import { PasswordUI } from "./ui/password"
|
||||
import { issuer } from "@openauthjs/openauth";
|
||||
import { User } from "@nestri/core/user/index"
|
||||
import { Email } from "@nestri/core/email/index";
|
||||
import { Email } from "@nestri/core/email/index"
|
||||
import { Users } from "@nestri/core/user/index"
|
||||
import { Teams } from "@nestri/core/team/index"
|
||||
import { authorizer } from "@openauthjs/openauth"
|
||||
import { Profiles } from "@nestri/core/profile/index"
|
||||
import { handleDiscord, handleGithub } from "./utils";
|
||||
import { type CFRequest } from "@nestri/core/types"
|
||||
import { GithubAdapter } from "./ui/adapters/github";
|
||||
import { DiscordAdapter } from "./ui/adapters/discord";
|
||||
import { Instances } from "@nestri/core/instance/index"
|
||||
import { PasswordAdapter } from "./ui/adapters/password"
|
||||
import { type Provider } from "@openauthjs/openauth/provider/provider"
|
||||
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
|
||||
import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
|
||||
import { Subscriptions } from "@nestri/core/subscription/index";
|
||||
import type { Subscription } from "./type";
|
||||
interface Env {
|
||||
CloudflareAuthKV: KVNamespace
|
||||
}
|
||||
|
||||
export type CodeAdapterState =
|
||||
| {
|
||||
type: "start"
|
||||
}
|
||||
| {
|
||||
type: "code"
|
||||
resend?: boolean
|
||||
code: string
|
||||
claims: Record<string, string>
|
||||
}
|
||||
|
||||
type OauthUser = {
|
||||
primary: {
|
||||
@@ -22,7 +45,10 @@ type OauthUser = {
|
||||
avatar: any;
|
||||
username: any;
|
||||
}
|
||||
const app = issuer({
|
||||
export default {
|
||||
async fetch(request: CFRequest, env: Env, ctx: ExecutionContext) {
|
||||
// const location = `${request.cf.country},${request.cf.continent}`
|
||||
return authorizer({
|
||||
select: Select({
|
||||
providers: {
|
||||
device: {
|
||||
@@ -48,6 +74,9 @@ const app = issuer({
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
`,
|
||||
},
|
||||
storage: CloudflareStorage({
|
||||
namespace: env.CloudflareAuthKV,
|
||||
}),
|
||||
subjects,
|
||||
providers: {
|
||||
github: GithubAdapter({
|
||||
@@ -64,12 +93,7 @@ const app = issuer({
|
||||
PasswordUI({
|
||||
sendCode: async (email, code) => {
|
||||
console.log("email & code:", email, code)
|
||||
// await Email.send(
|
||||
// "auth",
|
||||
// email,
|
||||
// `Nestri code: ${code}`,
|
||||
// `Your Nestri login code is ${code}`,
|
||||
// )
|
||||
await Email.send(email, code)
|
||||
},
|
||||
}),
|
||||
),
|
||||
@@ -95,7 +119,7 @@ const app = issuer({
|
||||
};
|
||||
},
|
||||
init() { }
|
||||
} as Provider<{ teamSlug: string; hostname: string; }>,
|
||||
} as Adapter<{ teamSlug: string; hostname: string; }>,
|
||||
},
|
||||
allow: async (input) => {
|
||||
const url = new URL(input.redirectURI);
|
||||
@@ -105,45 +129,35 @@ const app = issuer({
|
||||
return false;
|
||||
},
|
||||
success: async (ctx, value) => {
|
||||
// if (value.provider === "device") {
|
||||
// const team = await Teams.fromSlug(value.teamSlug)
|
||||
// console.log("team", team)
|
||||
// console.log("teamSlug", value.teamSlug)
|
||||
// if (team) {
|
||||
// await Instances.create({ hostname: value.hostname, teamID: team.id })
|
||||
if (value.provider === "device") {
|
||||
const team = await Teams.fromSlug(value.teamSlug)
|
||||
console.log("team", team)
|
||||
console.log("teamSlug", value.teamSlug)
|
||||
if (team) {
|
||||
await Instances.create({ hostname: value.hostname, teamID: team.id })
|
||||
|
||||
// return await ctx.subject("device", {
|
||||
// teamSlug: value.teamSlug,
|
||||
// hostname: value.hostname,
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
return await ctx.subject("device", {
|
||||
teamSlug: value.teamSlug,
|
||||
hostname: value.hostname,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (value.provider === "password") {
|
||||
const email = value.email
|
||||
const username = value.username
|
||||
const matching = await User.fromEmail(email)
|
||||
|
||||
//Sign Up
|
||||
if (username && !matching) {
|
||||
const userID = await User.create({
|
||||
name: username,
|
||||
email,
|
||||
});
|
||||
|
||||
if (!userID) throw new Error("Error creating user");
|
||||
|
||||
return ctx.subject("user", {
|
||||
userID,
|
||||
email
|
||||
});
|
||||
} else if (matching) {
|
||||
//Sign In
|
||||
return ctx.subject("user", {
|
||||
userID: matching.id,
|
||||
email
|
||||
});
|
||||
const token = await Users.create(email)
|
||||
const usr = await Users.fromEmail(email);
|
||||
const exists = await Profiles.fromOwnerID(usr.id)
|
||||
if (username && !exists) {
|
||||
await Profiles.create({ owner: usr.id, username })
|
||||
}
|
||||
|
||||
return await ctx.subject("user", {
|
||||
accessToken: token,
|
||||
userID: usr.id,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
let user = undefined as OauthUser | undefined;
|
||||
@@ -160,30 +174,19 @@ const app = issuer({
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
const matching = await User.fromEmail(user.primary.email);
|
||||
|
||||
//Sign Up
|
||||
if (!matching) {
|
||||
const userID = await User.create({
|
||||
email: user.primary.email,
|
||||
name: user.username,
|
||||
avatarUrl: user.avatar
|
||||
});
|
||||
|
||||
if (!userID) throw new Error("Error creating user");
|
||||
|
||||
return ctx.subject("user", {
|
||||
userID,
|
||||
email: user.primary.email
|
||||
});
|
||||
} else {
|
||||
//Sign In
|
||||
return await ctx.subject("user", {
|
||||
userID: matching.id,
|
||||
email: user.primary.email
|
||||
});
|
||||
const token = await Users.create(user.primary.email)
|
||||
const usr = await Users.fromEmail(user.primary.email);
|
||||
const exists = await Profiles.fromOwnerID(usr.id)
|
||||
console.log("exists", exists)
|
||||
if (!exists) {
|
||||
await Profiles.create({ owner: usr.id, avatarUrl: user.avatar, username: user.username })
|
||||
}
|
||||
|
||||
return await ctx.subject("user", {
|
||||
accessToken: token,
|
||||
userID: usr.id,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("error registering the user", error)
|
||||
}
|
||||
@@ -192,6 +195,6 @@ const app = issuer({
|
||||
|
||||
throw new Error("Something went seriously wrong");
|
||||
},
|
||||
}).use(logger())
|
||||
|
||||
export const handler = handle(app)
|
||||
}).fetch(request, env, ctx)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Email } from "@nestri/core/email/index"
|
||||
import { useActor } from "@nestri/core/actor";
|
||||
// import { Stripe } from "@nestri/core/stripe";
|
||||
// import { Template } from "@nestri/core/email/template";
|
||||
// import { EmailOctopus } from "@nestri/core/email-octopus";
|
||||
|
||||
export const handler = bus.subscriber(
|
||||
[User.Events.Updated, User.Events.Created],
|
||||
async (event) => {
|
||||
console.log(event.type, event.properties, event.metadata);
|
||||
switch (event.type) {
|
||||
// case "order.created": {
|
||||
// await Shippo.createShipment(event.properties.orderID);
|
||||
// await Template.sendOrderConfirmation(event.properties.orderID);
|
||||
// await EmailOctopus.addToCustomersList(event.properties.orderID);
|
||||
// break;
|
||||
// }
|
||||
case "user.created": {
|
||||
console.log("Send email here")
|
||||
// const actor = useActor()
|
||||
// if (actor.type !== "user") throw new Error("User actor is needed here")
|
||||
// await Email.send(
|
||||
// "welcome",
|
||||
// actor.properties.email,
|
||||
// `Welcome to Nestri`,
|
||||
// `Welcome to Nestri`,
|
||||
// )
|
||||
// await Stripe.syncUser(event.properties.userID);
|
||||
// // await EmailOctopus.addToMarketingList(event.properties.userID);
|
||||
// break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as v from "valibot"
|
||||
import { Subscription } from "./type"
|
||||
import { createSubjects } from "@openauthjs/openauth/subject"
|
||||
import { createSubjects } from "@openauthjs/openauth"
|
||||
|
||||
export const subjects = createSubjects({
|
||||
user: v.object({
|
||||
email: v.string(),
|
||||
userID: v.string(),
|
||||
accessToken: v.string(),
|
||||
userID: v.string()
|
||||
}),
|
||||
// device: v.object({
|
||||
// teamSlug: v.string(),
|
||||
// hostname: v.string(),
|
||||
// })
|
||||
device: v.object({
|
||||
teamSlug: v.string(),
|
||||
hostname: v.string(),
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Layout } from "../base"
|
||||
import { OauthError } from "@openauthjs/openauth/error"
|
||||
import { getRelativeUrl } from "@openauthjs/openauth/util"
|
||||
import { type Provider } from "@openauthjs/openauth/provider/provider"
|
||||
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
|
||||
|
||||
export interface Oauth2Config {
|
||||
type?: string
|
||||
@@ -32,7 +32,7 @@ interface AdapterState {
|
||||
|
||||
export function Oauth2Adapter(
|
||||
config: Oauth2Config,
|
||||
): Provider<{ tokenset: Oauth2Token; clientID: string }> {
|
||||
): Adapter<{ tokenset: Oauth2Token; clientID: string }> {
|
||||
const query = config.query || {}
|
||||
return {
|
||||
type: config.type || "oauth2",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// import { UnknownStateError } from "@openauthjs/openauth/error"
|
||||
import { Profiles } from "@nestri/core/profile/index"
|
||||
import { UnknownStateError } from "@openauthjs/openauth/error"
|
||||
import { Storage } from "@openauthjs/openauth/storage/storage"
|
||||
import { type Provider } from "@openauthjs/openauth/provider/provider"
|
||||
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
|
||||
import { generateUnbiasedDigits, timingSafeCompare } from "@openauthjs/openauth/random"
|
||||
|
||||
export interface PasswordHasher<T> {
|
||||
@@ -308,7 +309,7 @@ export function PasswordAdapter(config: PasswordConfig) {
|
||||
return transition({ type: "start", redirect: adapter.redirect })
|
||||
})
|
||||
},
|
||||
} satisfies Provider<{ email: string; username?:string }>
|
||||
} satisfies Adapter<{ email: string; username?:string }>
|
||||
}
|
||||
|
||||
import * as jose from "jose"
|
||||
@@ -377,7 +378,6 @@ export function PBKDF2Hasher(opts?: { interations?: number }): PasswordHasher<{
|
||||
}
|
||||
import { timingSafeEqual, randomBytes, scrypt } from "node:crypto"
|
||||
import { getRelativeUrl } from "@openauthjs/openauth/util"
|
||||
import { UnknownStateError } from "@openauthjs/openauth/error"
|
||||
|
||||
export function ScryptHasher(opts?: {
|
||||
N?: number
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
export const handleGithub = async (accessKey: string) => {
|
||||
console.log("acceskey", accessKey)
|
||||
|
||||
const headers = {
|
||||
Authorization: `token ${accessKey}`,
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
|
||||
64
packages/functions/sst-env.d.ts
vendored
64
packages/functions/sst-env.d.ts
vendored
@@ -6,34 +6,17 @@
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": {
|
||||
"type": "sst.aws.Router"
|
||||
"url": string
|
||||
}
|
||||
"ApiFn": {
|
||||
"name": string
|
||||
"type": "sst.aws.Function"
|
||||
"url": string
|
||||
}
|
||||
"Auth": {
|
||||
"type": "sst.aws.Auth"
|
||||
"url": string
|
||||
}
|
||||
"AuthFingerprintKey": {
|
||||
"type": "random.index/randomString.RandomString"
|
||||
"value": string
|
||||
}
|
||||
"Bus": {
|
||||
"arn": string
|
||||
"name": string
|
||||
"type": "sst.aws.Bus"
|
||||
"AwsAccessKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Database": {
|
||||
"host": string
|
||||
"name": string
|
||||
"password": string
|
||||
"type": "sst.sst.Linkable"
|
||||
"user": string
|
||||
"AwsSecretKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
@@ -51,26 +34,41 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Mail": {
|
||||
"configSet": string
|
||||
"sender": string
|
||||
"type": "sst.aws.Email"
|
||||
}
|
||||
"PolarSecret": {
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAppId": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"LoopsApiKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"NestriGPUCluster": {
|
||||
"type": "aws.ecs/cluster.Cluster"
|
||||
"value": string
|
||||
}
|
||||
"NestriGPUTask": {
|
||||
"type": "aws.ecs/taskDefinition.TaskDefinition"
|
||||
"value": string
|
||||
}
|
||||
"Urls": {
|
||||
"api": string
|
||||
"auth": string
|
||||
"site": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
"Web": {
|
||||
"type": "sst.aws.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types";
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": cloudflare.Service
|
||||
"Auth": cloudflare.Service
|
||||
"CloudflareAuthKV": cloudflare.KVNamespace
|
||||
}
|
||||
}
|
||||
|
||||
import "sst"
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
module relay
|
||||
|
||||
go 1.24
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/pion/ice/v4 v4.0.7
|
||||
github.com/pion/interceptor v0.1.37
|
||||
github.com/pion/webrtc/v4 v4.0.12
|
||||
google.golang.org/protobuf v1.36.5
|
||||
github.com/pion/webrtc/v4 v4.0.8
|
||||
google.golang.org/protobuf v1.36.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.4 // indirect
|
||||
github.com/pion/ice/v4 v4.0.5 // indirect
|
||||
github.com/pion/logging v0.2.3 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.15 // indirect
|
||||
github.com/pion/rtp v1.8.12 // indirect
|
||||
github.com/pion/sctp v1.8.36 // indirect
|
||||
github.com/pion/rtp v1.8.11 // indirect
|
||||
github.com/pion/sctp v1.8.35 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.10 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.4 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/turn/v4 v4.0.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/crypto v0.35.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
)
|
||||
|
||||
@@ -10,8 +10,8 @@ github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U=
|
||||
github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg=
|
||||
github.com/pion/ice/v4 v4.0.7 h1:mnwuT3n3RE/9va41/9QJqN5+Bhc0H/x/ZyiVlWMw35M=
|
||||
github.com/pion/ice/v4 v4.0.7/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||
github.com/pion/ice/v4 v4.0.5 h1:6awVfa1jg9YsI9/Lep4TG/o3kwS1Oayr5b8xz50ibJ8=
|
||||
github.com/pion/ice/v4 v4.0.5/go.mod h1:JJaoEIxUIlGDA9gaRZbwXYqI3j6VG/QchpjX+QmwN6A=
|
||||
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
|
||||
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
|
||||
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||
@@ -22,10 +22,10 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||
github.com/pion/rtp v1.8.12 h1:nsKs8Wi0jQyBFHU3qmn/OvtZrhktVfJY0vRxwACsL5U=
|
||||
github.com/pion/rtp v1.8.12/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4=
|
||||
github.com/pion/sctp v1.8.36 h1:owNudmnz1xmhfYje5L/FCav3V9wpPRePHle3Zi+P+M0=
|
||||
github.com/pion/sctp v1.8.36/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/rtp v1.8.11 h1:17xjnY5WO5hgO6SD3/NTIUPvSFw/PbLsIJyz1r1yNIk=
|
||||
github.com/pion/rtp v1.8.11/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4=
|
||||
github.com/pion/sctp v1.8.35 h1:qwtKvNK1Wc5tHMIYgTDJhfZk7vATGVHhXbUDfHbYwzA=
|
||||
github.com/pion/sctp v1.8.35/go.mod h1:EcXP8zCYVTRy3W9xtOF7wJm1L1aXfKRQzaM33SjQlzg=
|
||||
github.com/pion/sdp/v3 v3.0.10 h1:6MChLE/1xYB+CjumMw+gZ9ufp2DPApuVSnDT8t5MIgA=
|
||||
github.com/pion/sdp/v3 v3.0.10/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
|
||||
@@ -36,23 +36,23 @@ github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||
github.com/pion/webrtc/v4 v4.0.12 h1:/omInB15DdJDlA3WoAQAAhIQQvFCWNHdJ2t5e2+ozx4=
|
||||
github.com/pion/webrtc/v4 v4.0.12/go.mod h1:sMOtH6DSNVu6tfndczTMvJkKnyFVVeq+/G3dval418g=
|
||||
github.com/pion/webrtc/v4 v4.0.8 h1:T1ZmnT9qxIJIt4d8XoiMOBrTClGHDDXNg9e/fh018Qc=
|
||||
github.com/pion/webrtc/v4 v4.0.8/go.mod h1:HHBeUVBAC+j4ZFnYhovEFStF02Arb1EyD4G7e7HBTJw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
|
||||
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"log"
|
||||
@@ -39,7 +38,7 @@ func InitWebRTCAPI() error {
|
||||
PayloadType: 49,
|
||||
},
|
||||
} {
|
||||
if err = mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil {
|
||||
if err := mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -59,27 +58,11 @@ func InitWebRTCAPI() error {
|
||||
// New in v4, reduces CPU usage and latency when enabled
|
||||
settingEngine.EnableSCTPZeroChecksum(true)
|
||||
|
||||
nat11IPs := GetFlags().NAT11IPs
|
||||
if len(nat11IPs) > 0 {
|
||||
settingEngine.SetNAT1To1IPs(nat11IPs, webrtc.ICECandidateTypeHost)
|
||||
}
|
||||
|
||||
muxPort := GetFlags().UDPMuxPort
|
||||
if muxPort > 0 {
|
||||
mux, err := ice.NewMultiUDPMuxFromPort(muxPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settingEngine.SetICEUDPMux(mux)
|
||||
} else {
|
||||
// Set the UDP port range used by WebRTC
|
||||
err = settingEngine.SetEphemeralUDPPortRange(uint16(flags.WebRTCUDPStart), uint16(flags.WebRTCUDPEnd))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
settingEngine.SetIncludeLoopbackCandidate(true) // Just in case
|
||||
|
||||
// Create a new API object with our customized settings
|
||||
globalWebRTCAPI = webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine), webrtc.WithSettingEngine(settingEngine), webrtc.WithInterceptorRegistry(interceptorRegistry))
|
||||
@@ -105,7 +88,7 @@ func CreatePeerConnection(onClose func()) (*webrtc.PeerConnection, error) {
|
||||
if connectionState == webrtc.PeerConnectionStateFailed ||
|
||||
connectionState == webrtc.PeerConnectionStateDisconnected ||
|
||||
connectionState == webrtc.PeerConnectionStateClosed {
|
||||
err = pc.Close()
|
||||
err := pc.Close()
|
||||
if err != nil {
|
||||
log.Printf("Error closing PeerConnection: %s\n", err.Error())
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func participantHandler(participant *Participant, room *Room) {
|
||||
}
|
||||
|
||||
// Data channel settings
|
||||
settingOrdered := true
|
||||
settingOrdered := false
|
||||
settingMaxRetransmits := uint16(0)
|
||||
dc, err := participant.PeerConnection.CreateDataChannel("data", &webrtc.DataChannelInit{
|
||||
Ordered: &settingOrdered,
|
||||
@@ -75,11 +75,14 @@ func participantHandler(participant *Participant, room *Room) {
|
||||
log.Printf("Failed to marshal input message for participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err = room.DataChannel.SendBinary(data); err != nil {
|
||||
log.Printf("Failed to send input message to room: '%s' - reason: %s\n", room.Name, err)
|
||||
}
|
||||
} else {
|
||||
if err = room.DataChannel.SendBinary(data); err != nil {
|
||||
log.Printf("Failed to send input message to room: '%s' - reason: %s\n", room.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,28 +2,22 @@ package relay
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
var globalFlags *Flags
|
||||
|
||||
type Flags struct {
|
||||
Verbose bool // Verbose mode - log more information to console
|
||||
Debug bool // Debug mode - log deeper debug information to console
|
||||
EndpointPort int // Port for HTTP/S and WS/S endpoint (TCP)
|
||||
WebRTCUDPStart int // WebRTC UDP port range start - ignored if UDPMuxPort is set
|
||||
WebRTCUDPEnd int // WebRTC UDP port range end - ignored if UDPMuxPort is set
|
||||
STUNServer string // WebRTC STUN server
|
||||
UDPMuxPort int // WebRTC UDP mux port - if set, overrides UDP port range
|
||||
AutoAddLocalIP bool // Automatically add local IP to NAT 1 to 1 IPs
|
||||
NAT11IPs []string // WebRTC NAT 1 to 1 IP(s) - allows specifying host IP(s) if behind NAT
|
||||
TLSCert string // Path to TLS certificate
|
||||
TLSKey string // Path to TLS key
|
||||
Verbose bool
|
||||
Debug bool
|
||||
EndpointPort int
|
||||
WebRTCUDPStart int
|
||||
WebRTCUDPEnd int
|
||||
STUNServer string
|
||||
}
|
||||
|
||||
func (flags *Flags) DebugLog() {
|
||||
@@ -34,13 +28,6 @@ func (flags *Flags) DebugLog() {
|
||||
log.Println("> WebRTC UDP Range Start: ", flags.WebRTCUDPStart)
|
||||
log.Println("> WebRTC UDP Range End: ", flags.WebRTCUDPEnd)
|
||||
log.Println("> WebRTC STUN Server: ", flags.STUNServer)
|
||||
log.Println("> WebRTC UDP Mux Port: ", flags.UDPMuxPort)
|
||||
log.Println("> Auto Add Local IP: ", flags.AutoAddLocalIP)
|
||||
for i, ip := range flags.NAT11IPs {
|
||||
log.Printf("> WebRTC NAT 1 to 1 IP (%d): %s\n", i, ip)
|
||||
}
|
||||
log.Println("> Path to TLS Cert: ", flags.TLSCert)
|
||||
log.Println("> Path to TLS Key: ", flags.TLSKey)
|
||||
}
|
||||
|
||||
func getEnvAsInt(name string, defaultVal int) int {
|
||||
@@ -79,13 +66,6 @@ func InitFlags() {
|
||||
flag.IntVar(&globalFlags.WebRTCUDPStart, "webrtcUDPStart", getEnvAsInt("WEBRTC_UDP_START", 10000), "WebRTC UDP port range start")
|
||||
flag.IntVar(&globalFlags.WebRTCUDPEnd, "webrtcUDPEnd", getEnvAsInt("WEBRTC_UDP_END", 20000), "WebRTC UDP port range end")
|
||||
flag.StringVar(&globalFlags.STUNServer, "stunServer", getEnvAsString("STUN_SERVER", "stun.l.google.com:19302"), "WebRTC STUN server")
|
||||
flag.IntVar(&globalFlags.UDPMuxPort, "webrtcUDPMux", getEnvAsInt("WEBRTC_UDP_MUX", 8088), "WebRTC UDP mux port")
|
||||
flag.BoolVar(&globalFlags.AutoAddLocalIP, "autoAddLocalIP", getEnvAsBool("AUTO_ADD_LOCAL_IP", true), "Automatically add local IP to NAT 1 to 1 IPs")
|
||||
// String with comma separated IPs
|
||||
nat11IPs := ""
|
||||
flag.StringVar(&nat11IPs, "webrtcNAT11IPs", getEnvAsString("WEBRTC_NAT_IPS", ""), "WebRTC NAT 1 to 1 IP(s)")
|
||||
flag.StringVar(&globalFlags.TLSCert, "tlsCert", getEnvAsString("TLS_CERT", ""), "Path to TLS certificate")
|
||||
flag.StringVar(&globalFlags.TLSKey, "tlsKey", getEnvAsString("TLS_KEY", ""), "Path to TLS key")
|
||||
// Parse flags
|
||||
flag.Parse()
|
||||
|
||||
@@ -95,44 +75,8 @@ func InitFlags() {
|
||||
URLs: []string{"stun:" + globalFlags.STUNServer},
|
||||
},
|
||||
}
|
||||
|
||||
// Initialize NAT 1 to 1 IPs
|
||||
globalFlags.NAT11IPs = []string{}
|
||||
|
||||
// Get local IP
|
||||
if globalFlags.AutoAddLocalIP {
|
||||
globalFlags.NAT11IPs = append(globalFlags.NAT11IPs, getLocalIP())
|
||||
}
|
||||
|
||||
// Parse NAT 1 to 1 IPs from string
|
||||
if len(nat11IPs) > 0 {
|
||||
split := strings.Split(nat11IPs, ",")
|
||||
if len(split) > 0 {
|
||||
for _, ip := range split {
|
||||
globalFlags.NAT11IPs = append(globalFlags.NAT11IPs, ip)
|
||||
}
|
||||
} else {
|
||||
globalFlags.NAT11IPs = append(globalFlags.NAT11IPs, nat11IPs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetFlags() *Flags {
|
||||
return globalFlags
|
||||
}
|
||||
|
||||
// getLocalIP returns local IP, be it either IPv4 or IPv6, skips loopback addresses
|
||||
func getLocalIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, address := range addrs {
|
||||
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil || ipnet.IP != nil {
|
||||
return ipnet.IP.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package relay
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/gorilla/websocket"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -11,7 +10,7 @@ import (
|
||||
|
||||
var httpMux *http.ServeMux
|
||||
|
||||
func InitHTTPEndpoint() error {
|
||||
func InitHTTPEndpoint() {
|
||||
// Create HTTP mux which serves our WS endpoint
|
||||
httpMux = http.NewServeMux()
|
||||
|
||||
@@ -21,11 +20,8 @@ func InitHTTPEndpoint() error {
|
||||
|
||||
// Get our serving port
|
||||
port := GetFlags().EndpointPort
|
||||
tlsCert := GetFlags().TLSCert
|
||||
tlsKey := GetFlags().TLSKey
|
||||
|
||||
// Log and start the endpoint server
|
||||
if len(tlsCert) <= 0 && len(tlsKey) <= 0 {
|
||||
log.Println("Starting HTTP endpoint server on :", strconv.Itoa(port))
|
||||
go func() {
|
||||
log.Fatal((&http.Server{
|
||||
@@ -33,18 +29,6 @@ func InitHTTPEndpoint() error {
|
||||
Addr: ":" + strconv.Itoa(port),
|
||||
}).ListenAndServe())
|
||||
}()
|
||||
} else if len(tlsCert) > 0 && len(tlsKey) > 0 {
|
||||
log.Println("Starting HTTPS endpoint server on :", strconv.Itoa(port))
|
||||
go func() {
|
||||
log.Fatal((&http.Server{
|
||||
Handler: httpMux,
|
||||
Addr: ":" + strconv.Itoa(port),
|
||||
}).ListenAndServeTLS(tlsCert, tlsKey))
|
||||
}()
|
||||
} else {
|
||||
return errors.New("no TLS certificate or TLS key provided")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logHTTPError logs (if verbose) and sends an error code to requester
|
||||
|
||||
@@ -31,42 +31,29 @@ if [ ! -e "$PIPEWIRE_SOCKET" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Detecting GPU vendor..."
|
||||
echo "Detecting GPU vendor and installing necessary GStreamer plugins..."
|
||||
source /etc/nestri/gpu_helpers.sh
|
||||
|
||||
get_gpu_info
|
||||
|
||||
# Check for NVIDIA so we can apply a workaround
|
||||
# Check vendors in priority order
|
||||
if [[ -n "${vendor_devices[nvidia]:-}" ]]; then
|
||||
echo "NVIDIA GPU detected, applying driver fix..."
|
||||
# Determine NVIDIA driver version from host
|
||||
if [ -f "/proc/driver/nvidia/version" ]; then
|
||||
NVIDIA_DRIVER_VERSION=$(head -n1 /proc/driver/nvidia/version | awk '{for(i=1;i<=NF;i++) if ($i ~ /^[0-9]+\.[0-9\.]+/) {print $i; exit}}')
|
||||
elif command -v nvidia-smi &> /dev/null; then
|
||||
NVIDIA_DRIVER_VERSION=$(nvidia-smi --version | grep -i 'driver version' | cut -d: -f2 | tr -d ' ')
|
||||
echo "NVIDIA GPU detected, assuming driver is linked and applying Vulkan fix..."
|
||||
echo "{\"file_format_version\":\"1.0.0\",\"ICD\":{\"library_path\":\"libGLX_nvidia.so.0\",\"api_version\":\"1.3\"}}" > /usr/share/vulkan/icd.d/nvidia_icd.json
|
||||
elif [[ -n "${vendor_devices[intel]:-}" ]]; then
|
||||
echo "Intel GPU detected, installing required packages..."
|
||||
pacman -Sy --noconfirm gstreamer-vaapi gst-plugin-va gst-plugin-qsv
|
||||
pacman -Sy --noconfirm vpl-gpu-rt
|
||||
elif [[ -n "${vendor_devices[amd]:-}" ]]; then
|
||||
echo "AMD GPU detected, installing required packages..."
|
||||
pacman -Sy --noconfirm gstreamer-vaapi gst-plugin-va
|
||||
else
|
||||
echo "Failed to determine NVIDIA driver version. Exiting."
|
||||
exit 1
|
||||
echo "Unknown GPU vendor. No additional packages will be installed"
|
||||
fi
|
||||
|
||||
NVIDIA_DRIVER_ARCH=$(uname -m)
|
||||
filename="NVIDIA-Linux-${NVIDIA_DRIVER_ARCH}-${NVIDIA_DRIVER_VERSION}.run"
|
||||
|
||||
cd /tmp/
|
||||
if [ ! -f "${filename}" ]; then
|
||||
# Attempt multiple download sources
|
||||
if ! wget "https://international.download.nvidia.com/XFree86/Linux-${NVIDIA_DRIVER_ARCH}/${NVIDIA_DRIVER_VERSION}/${filename}"; then
|
||||
if ! wget "https://international.download.nvidia.com/tesla/${NVIDIA_DRIVER_VERSION}/${filename}"; then
|
||||
echo "Failed to download NVIDIA driver from both XFree86 and Tesla repositories"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
chmod +x "${filename}"
|
||||
# Install driver components without kernel modules
|
||||
sudo ./"${filename}" --silent --no-kernel-module --install-compat32-libs --no-nouveau-check --no-nvidia-modprobe --no-systemd --no-rpms --no-backup --no-check-for-alternate-installs
|
||||
fi
|
||||
fi
|
||||
# Clean up remainders
|
||||
echo "Cleaning up old package cache..."
|
||||
paccache -rk1
|
||||
|
||||
echo "Switching to nestri user for application startup..."
|
||||
exec sudo -E -u nestri /etc/nestri/entrypoint_nestri.sh
|
||||
|
||||
@@ -42,7 +42,7 @@ start_nestri_server() {
|
||||
|
||||
# Wait for Wayland display (wayland-1) to be ready
|
||||
echo "Waiting for Wayland display 'wayland-1' to be ready..."
|
||||
WAYLAND_SOCKET="${XDG_RUNTIME_DIR}/wayland-1"
|
||||
WAYLAND_SOCKET="/run/user/${UID}/wayland-1"
|
||||
for _ in {1..15}; do # Wait up to 15 seconds
|
||||
if [ -e "$WAYLAND_SOCKET" ]; then
|
||||
echo "Wayland display 'wayland-1' is ready."
|
||||
@@ -69,11 +69,6 @@ start_compositor() {
|
||||
kill "${COMPOSITOR_PID}"
|
||||
fi
|
||||
|
||||
echo "Pre-configuring compositor..."
|
||||
mkdir -p "${HOME}/.config/labwc/"
|
||||
echo '<?xml version="1.0" encoding="UTF-8"?><labwc_config><keyboard><default/></keyboard><mouse><default/><context name="Root"><mousebind button="Left" action="Press"/><mousebind button="Right" action="Press"/><mousebind button="Middle" action="Press"/></context></mouse></labwc_config>' > ~/.config/labwc/rc.xml
|
||||
echo '<?xml version="1.0" encoding="UTF-8"?><openbox_menu></openbox_menu>' > ~/.config/labwc/menu.xml
|
||||
|
||||
echo "Starting compositor..."
|
||||
rm -rf /tmp/.X11-unix && mkdir -p /tmp/.X11-unix && chown nestri:nestri /tmp/.X11-unix
|
||||
WAYLAND_DISPLAY=wayland-1 WLR_BACKENDS=wayland labwc &
|
||||
@@ -81,11 +76,11 @@ start_compositor() {
|
||||
|
||||
# Wait for compositor to initialize
|
||||
echo "Waiting for compositor to initialize..."
|
||||
COMPOSITOR_SOCKET="${XDG_RUNTIME_DIR}/wayland-0"
|
||||
COMPOSITOR_SOCKET="/run/user/${UID}/wayland-0"
|
||||
for _ in {1..15}; do
|
||||
if [ -e "$COMPOSITOR_SOCKET" ]; then
|
||||
echo "compositor is initialized, wayland-0 output ready."
|
||||
sleep 3 # necessary sleep - reduces chance that non-ready socket is used
|
||||
sleep 1 # necessary sleep - reduces chance that non-ready socket is used
|
||||
start_wlr_randr
|
||||
return
|
||||
fi
|
||||
@@ -106,8 +101,8 @@ start_wlr_randr() {
|
||||
echo "Configuring resolution with wlr-randr..."
|
||||
OUTPUT_NAME=$(WAYLAND_DISPLAY=wayland-0 wlr-randr --json | jq -r '.[] | select(.enabled == true) | .name' | head -n 1)
|
||||
if [ -z "$OUTPUT_NAME" ]; then
|
||||
echo "Error: No enabled outputs detected, exiting."
|
||||
exit 1
|
||||
echo "Error: No enabled outputs detected. Skipping wlr-randr."
|
||||
return
|
||||
fi
|
||||
|
||||
# Retry logic for wlr-randr
|
||||
@@ -116,13 +111,12 @@ start_wlr_randr() {
|
||||
echo "Error: Failed to configure wlr-randr. Retrying..."
|
||||
((WLR_RETRIES++))
|
||||
if [ "$WLR_RETRIES" -ge "$MAX_RETRIES" ]; then
|
||||
echo "Max retries reached for wlr-randr, exiting."
|
||||
exit 1
|
||||
echo "Max retries reached for wlr-randr. Moving on without resolution setup."
|
||||
return
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "wlr-randr configuration successful."
|
||||
sleep 2 # necessary sleep - makes sure resolution is changed before next step(s)
|
||||
}
|
||||
|
||||
# Function to start Steam
|
||||
|
||||
@@ -3,9 +3,11 @@ set -euo pipefail
|
||||
|
||||
export XDG_RUNTIME_DIR=/run/user/${UID}/
|
||||
export WAYLAND_DISPLAY=wayland-0
|
||||
export XDG_SESSION_TYPE=wayland
|
||||
export DISPLAY=:0
|
||||
export $(dbus-launch)
|
||||
|
||||
# Fixes freezing issue
|
||||
export PROTON_NO_FSYNC=1
|
||||
|
||||
# Our preferred prefix
|
||||
export WINEPREFIX=/home/${USER}/.nestripfx/
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Resource } from "sst";
|
||||
import { spawnSync } from "bun";
|
||||
|
||||
spawnSync(
|
||||
[
|
||||
"psql",
|
||||
`postgresql://${Resource.Database.user}:${Resource.Database.password}@${Resource.Database.host}/${Resource.Database.name}?sslmode=require`,
|
||||
],
|
||||
{
|
||||
stdout: "inherit",
|
||||
stdin: "inherit",
|
||||
stderr: "inherit",
|
||||
},
|
||||
);
|
||||
1059
packages/server/Cargo.lock
generated
1059
packages/server/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user