31 Commits

Author SHA1 Message Date
Wanjohi
8aa983834c Merge branch 'main' into feat/play 2025-03-16 00:11:54 +03:00
AquaWolf
5189bf768a merge 2025-03-15 21:06:30 +01:00
Wanjohi
74f8208fa4 🐜 fix: Fix dynamodb errors when trying to authenticate (#208)
## Description
This fixes an issue where `openauthjs` throws timeout errors while
attempting to access DynamoDB
> I am so glad this was not a major issue 😅
## Related Issues
[OpenAuth Error TypeError: fetch failed while accessing DynamoDB on AWS
#5543
](https://github.com/sst/sst/issues/5543)
[Error TypeError: fetch failed while accessing DynamoDB on AWS
#220](https://github.com/toolbeam/openauth/issues/220)
## Type of Change

- [x] Bug fix (non-breaking change)
- [ ] New feature (non-breaking change)
- [ ] Breaking change (fix or feature that changes existing
functionality)
- [ ] Documentation update
- [ ] Other (please describe):

## Checklist

- [x] I have updated relevant documentation
- [ ] My code follows the project's coding style
- [ ] My changes generate no new warnings/errors

## Screenshots/Demo

![image](https://github.com/user-attachments/assets/03656741-4e74-4947-8a15-333f5cf19dc8)
2025-03-10 04:43:14 +03:00
Wanjohi
b251584ccb 🧹 chore(www): Update the onboarding (#207)
## Description
<!-- Briefly describe the purpose and scope of your changes -->

## Related Issues
<!-- List any related issues (e.g., "Closes #123", "Fixes #456") -->

## Type of Change

- [x] Bug fix (non-breaking change)
- [ ] New feature (non-breaking change)
- [ ] Breaking change (fix or feature that changes existing
functionality)
- [ ] Documentation update
- [ ] Other (please describe):

## Checklist

- [ ] I have updated relevant documentation
- [ ] My code follows the project's coding style
- [ ] My changes generate no new warnings/errors

## Notes for Reviewers
<!-- Point out areas you'd like reviewers to focus on, questions you
have, or decisions that need discussion -->

## Screenshots/Demo
<!-- If applicable, add screenshots or a GIF demo of your changes
(especially for UI changes) -->

## Additional Context
<!-- Add any other context about the pull request here -->
2025-03-10 04:06:03 +03:00
AquaWolf
b734892c55 extracted modal component + showing modal on enter and mouspointer loss 2025-03-09 14:53:11 +01:00
dependabot[bot]
15825c70e6 build(deps): bump ring from 0.17.11 to 0.17.13 in /packages/server in the cargo group across 1 directory (#205)
Bumps the cargo group with 1 update in the /packages/server directory:
[ring](https://github.com/briansmith/ring).

Updates `ring` from 0.17.11 to 0.17.13
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/briansmith/ring/blob/main/RELEASES.md">ring's
changelog</a>.</em></p>
<blockquote>
<h1>Version 0.17.13 (2025-03-06)</h1>
<p>Increased MSRV to 1.66.0 to avoid bugs in earlier versions so that we
can
safely use <code>core::arch::x86_64::__cpuid</code> and
<code>core::arch::x86::__cpuid</code> from
Rust in future releases.</p>
<p>AVX2-based VAES-CLMUL implementation. This will be a notable
performance
improvement for most newish x86-64 systems. This will likely raise the
minimum
binutils version supported for very old Linux distros.</p>
<h1>Version 0.17.12 (2025-03-05)</h1>
<p>Bug fix: <a
href="https://redirect.github.com/briansmith/ring/pull/2447">briansmith/ring#2447</a>
for denial of service (DoS).</p>
<ul>
<li>
<p>Fixes a panic in
<code>ring::aead::quic::HeaderProtectionKey::new_mask()</code> when
integer overflow checking is enabled. In the QUIC protocol, an attacker
can
induce this panic by sending a specially-crafted packet. Even
unintentionally
it is likely to occur in 1 out of every 2**32 packets sent and/or
received.</p>
</li>
<li>
<p>Fixes a panic on 64-bit targets in <code>ring::aead::{AES_128_GCM,
AES_256_GCM}</code>
when overflow checking is enabled, when encrypting/decrypting
approximately
68,719,476,700 bytes (about 64 gigabytes) of data in a single chunk.
Protocols
like TLS and SSH are not affected by this because those protocols break
large
amounts of data into small chunks. Similarly, most applications will not
attempt to encrypt/decrypt 64GB of data in one chunk.</p>
</li>
</ul>
<p>Overflow checking is not enabled in release mode by default, but
<code>RUSTFLAGS=&quot;-C overflow-checks&quot;</code> or
<code>overflow-checks = true</code> in the Cargo.toml
profile can override this. Overflow checking is usually enabled by
default in
debug mode.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/briansmith/ring/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ring&package-manager=cargo&previous-version=0.17.11&new-version=0.17.13)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/nestrilabs/nestri/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-08 10:42:21 +02:00
Wanjohi
117503081b feat(www): Finish up on the onboarding (#202)
## Description
This is an attempt to finish up on the onboarding and creating a team

## Type of Change

- [ ] Bug fix (non-breaking change)
- [x] New feature (non-breaking change)
- [ ] Breaking change (fix or feature that changes existing
functionality)
- [ ] Documentation update
- [ ] Other (please describe):

## Checklist

- [ ] I have updated relevant documentation
- [x] My code follows the project's coding style
- [x] My changes generate no new warnings/errors
2025-03-05 22:44:00 +03:00
AquaWolf
402e894224 added modal with correct styling. Open: need to wire the modals correctly and have the welcome modal 2025-03-03 22:28:53 +01:00
Wanjohi
fb0cb0b6ca fix: Portal mount 2025-03-03 23:47:00 +03:00
Wanjohi
4fd339b55f fix: Have a root component 2025-03-03 23:42:58 +03:00
Wanjohi
1e78238593 fix: Colors 2025-03-03 23:36:18 +03:00
AquaWolf
c994dc112c added new App changes and background for theme 2025-03-03 21:16:27 +01:00
AquaWolf
a727a9b710 some wip with styles 2025-03-03 21:07:13 +01:00
AquaWolf
805a8a6115 some changes to the play route 2025-03-03 17:39:38 +01:00
Wanjohi
05aa177681 Merge branch 'main' into feat/play 2025-03-03 15:00:31 +03:00
Wanjohi
1aeafec40b 🐜 fix(db): Migrate to Neon org account (#204)
## Description
Migrates to a neon account shared by the Nestrilabs organisation

## Related Issues
<!-- List any related issues (e.g., "Closes #123", "Fixes #456") -->

## Type of Change

- [ ] Bug fix (non-breaking change)
- [ ] New feature (non-breaking change)
- [ ] Breaking change (fix or feature that changes existing
functionality)
- [ ] Documentation update
- [ ] Other (please describe):

## Checklist

- [ ] I have updated relevant documentation
- [ ] My code follows the project's coding style
- [ ] My changes generate no new warnings/errors

## Notes for Reviewers
<!-- Point out areas you'd like reviewers to focus on, questions you
have, or decisions that need discussion -->

## Screenshots/Demo
<!-- If applicable, add screenshots or a GIF demo of your changes
(especially for UI changes) -->

## Additional Context
<!-- Add any other context about the pull request here -->
2025-03-03 14:59:48 +03:00
Wanjohi
9ab4b6580c 🐜 fix(db): Fix database state issues (#203)
## Description
This attempts to fix all database issues for all teammates

## Related Issues
1. Getting funny errors while trying to run `sst dev`
2. The neon pulumi stuff trying to create a db each time on push

## Type of Change

- [x] Bug fix (non-breaking change)
- [ ] New feature (non-breaking change)
- [ ] Breaking change (fix or feature that changes existing
functionality)
- [ ] Documentation update
- [ ] Other (please describe):

## Checklist

- [ ] I have updated relevant documentation
- [ ] My code follows the project's coding style
- [x] My changes generate no new warnings/errors

## Notes for Reviewers
<!-- Point out areas you'd like reviewers to focus on, questions you
have, or decisions that need discussion -->

## Screenshots/Demo
<!-- If applicable, add screenshots or a GIF demo of your changes
(especially for UI changes) -->

## Additional Context
<!-- Add any other context about the pull request here -->
2025-03-03 13:36:13 +03:00
Kristian Ollikainen
49853807a1 feat(relay): Port muxing and TLS (#197)
## Description
This PR will work on adding port muxing (share single port for HTTP/WS +
WebRTC connections), along with API communication.

## Type of Change

- [x] Bug fix (non-breaking change)
- [x] New feature (non-breaking change)

## Checklist

- [ ] I have updated relevant documentation
- [x] My code follows the project's coding style
- [x] My changes generate no new warnings/errors

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
2025-03-02 18:47:25 +03:00
Wanjohi
421fcb067c Merge branch 'main' into feat/play 2025-03-02 14:58:11 +03:00
Wanjohi
321dda60d9 🐜 fix(www): Error Missing "./400.css" specifier in "@fontsource/geist-mono" (#201)
## Description
Attempts to fix the build issue (again) where the non-variable fonts
from font-source are throwing `Missing "./*00.css" specifier in
"@fontsource/geist-*` while building or developing locally... like damn!

## Related Issues
<!-- List any related issues (e.g., "Closes #123", "Fixes #456") -->

## Type of Change

- [x] Bug fix (non-breaking change)
- [ ] New feature (non-breaking change)
- [ ] Breaking change (fix or feature that changes existing
functionality)
- [ ] Documentation update
- [ ] Other (please describe):

## Checklist

- [x] I have updated relevant documentation
- [x] My code follows the project's coding style
- [ ] My changes generate no new warnings/errors
2025-03-02 14:54:20 +03:00
Wanjohi
fb47bb6699 🐜 fix: Remove old.sst.config.ts file 2025-03-02 01:47:00 +03:00
Wanjohi
7dee7e480b Merge branch 'main' into feat/play 2025-03-02 01:31:37 +03:00
Wanjohi
849a470073 🐜 fix(infra): Share the same Neon project 2025-03-02 01:30:57 +03:00
Wanjohi
1a49c709f7 Merge branch 'main' into feat/play 2025-03-02 00:11:22 +03:00
Wanjohi
178c612f0f 🐜 fix: Make sure the db uses the same name (#199)
## Description
This fixes the issue where Cloudflare fails

## Related Issues
<!-- List any related issues (e.g., "Closes #123", "Fixes #456") -->

## Type of Change

- [x] Bug fix (non-breaking change)
- [ ] New feature (non-breaking change)
- [ ] Breaking change (fix or feature that changes existing
functionality)
- [ ] Documentation update
- [ ] Other (please describe):

## Checklist

- [x] I have updated relevant documentation
- [x] My code follows the project's coding style
- [x] My changes generate no new warnings/errors
2025-03-02 00:01:25 +03:00
Kristian Ollikainen
b18b08b822 feat(runner): Rust updates and improvements (#196)
## Description
- Updates to latest Rust 2024 🎉
- Make DataChannel messages ordered on nestri-server side
- Bugfixes and code improvements + formatting

## Type of Change

- [x] Bug fix (non-breaking change)
- [x] New feature (non-breaking change)

## Checklist

- [x] I have updated relevant documentation
- [x] My code follows the project's coding style
- [x] My changes generate no new warnings/errors

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Wanjohi <elviswanjohi47@gmail.com>
2025-03-01 21:57:54 +02:00
AquaWolf
90e0533fdd right parameter 2025-02-28 21:47:46 +01:00
AquaWolf
058ac24954 added first draft of the play route 2025-02-28 21:30:23 +01:00
dependabot[bot]
ea96fed4f6 build(deps-dev): bump vite from 5.4.10 to 5.4.12 in /packages/www in the npm_and_yarn group across 1 directory (#195)
Bumps the npm_and_yarn group with 1 update in the /packages/www
directory:
[vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).

Updates `vite` from 5.4.10 to 5.4.12
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v5.4.12</h2>
<p>This version contains a breaking change due to security fixes. See <a
href="https://github.com/vitejs/vite/security/advisories/GHSA-vg6x-rcgg-rjx6">https://github.com/vitejs/vite/security/advisories/GHSA-vg6x-rcgg-rjx6</a>
for more details.</p>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v5.4.11</h2>
<p>Please refer to <a
href="ecd2375460/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted -->5.4.12 (2025-01-20)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix!: check host header to prevent DNS rebinding attacks and
introduce <code>server.allowedHosts</code> (<a
href="9da4abc8dd">9da4abc</a>)</li>
<li>fix!: default <code>server.cors: false</code> to disallow fetching
from untrusted origins (<a
href="dfea38f1ff">dfea38f</a>)</li>
<li>fix: verify token for HMR WebSocket connection (<a
href="b71a5c89a1">b71a5c8</a>)</li>
<li>chore: add deps update changelog (<a
href="ecd2375460">ecd2375</a>)</li>
</ul>
<h2><!-- raw HTML omitted -->5.4.11 (2024-11-11)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix(deps): update dependencies of postcss-modules (<a
href="ceb15db613">ceb15db</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18617">#18617</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="f428aa9af8"><code>f428aa9</code></a>
release: v5.4.12</li>
<li><a
href="9da4abc8dd"><code>9da4abc</code></a>
fix!: check host header to prevent DNS rebinding attacks and introduce
`serve...</li>
<li><a
href="b71a5c89a1"><code>b71a5c8</code></a>
fix: verify token for HMR WebSocket connection</li>
<li><a
href="dfea38f1ff"><code>dfea38f</code></a>
fix!: default <code>server.cors: false</code> to disallow fetching from
untrusted origins</li>
<li><a
href="ecd2375460"><code>ecd2375</code></a>
chore: add deps update changelog</li>
<li><a
href="c54c860f9d"><code>c54c860</code></a>
release: v5.4.11</li>
<li>See full diff in <a
href="https://github.com/vitejs/vite/commits/v5.4.12/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vite&package-manager=npm_and_yarn&previous-version=5.4.10&new-version=5.4.12)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/nestrilabs/nestri/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-27 21:41:21 +03:00
Wanjohi
457aac2258 feat(infra): Update infra and add support for teams to SST (#186)
## Description
- [x] Adds support for AWS SSO, which makes us (the team) able to use
SST and update the components independently
- [x] Splits the webpage into the landing page (Qwik), and Astro (the
console) in charge of playing. This allows us to pass in Environment
Variables to the console
- ~Migrates the docs from Nuxt to Nextjs, and connects them to SST. This
allows us to use Fumadocs _citation needed_ that's much more beautiful,
and supports OpenApi~
- Cloudflare pages with github integration is not working on our new CF
account. So we will have to push the pages deployment manually with
Github actions
- [x] Moves the current set up from my personal CF and AWS accounts to
dedicated Nestri accounts -

## Related Issues
<!-- List any related issues (e.g., "Closes #123", "Fixes #456") -->

## Type of Change

- [ ] Bug fix (non-breaking change)
- [x] New feature (non-breaking change)
- [ ] Breaking change (fix or feature that changes existing
functionality)
- [x] Documentation update
- [ ] Other (please describe):

## Checklist

- [x] I have updated relevant documentation
- [x] My code follows the project's coding style
- [x] My changes generate no new warnings/errors

## Notes for Reviewers
<!-- Point out areas you'd like reviewers to focus on, questions you
have, or decisions that need discussion -->
Please approve my PR 🥹


## Screenshots/Demo
<!-- If applicable, add screenshots or a GIF demo of your changes
(especially for UI changes) -->

## Additional Context
<!-- Add any other context about the pull request here -->
2025-02-27 18:52:05 +03:00
Kristian Ollikainen
237e016b2d 🐜 fix(runner): Workarounds for NVIDIA drivers (#188)
#### Description

This PR will be fixing issue of runner not working under Ubuntu and
other Debian-based distros with NVIDIA GPUs.
Another fix will be arriving is allowing Steam to run without namespace
requirements, removing the need for `--privileged` flag in certain
situations.

#### Type of Change

- [x] Bug fix (non-breaking change)

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
2025-02-27 17:37:23 +02:00
156 changed files with 10853 additions and 5975 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
CLOUDFLARE_API_TOKEN=
NEON_API_KEY=

View File

@@ -7,6 +7,7 @@ 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
@@ -16,6 +17,7 @@ on:
- "containers/runner.Containerfile"
- ".github/workflows/runner.yml"
- "packages/scripts/**"
- "packages/server/**"
tags:
- v*.*.*
release:

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ node_modules
# Local env files
.env
.env.local
.env.sst
.env.development.local
.env.test.local
.env.production.local

View File

@@ -1,4 +0,0 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "unifiedjs.vscode-mdx"],
"unwantedRecommendations": []
}

24
.vscode/launch.json vendored
View File

@@ -1,24 +0,0 @@
{
// 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"]
}
]
}

View File

@@ -1,36 +0,0 @@
{
"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",
"});",
],
},
}

View File

@@ -1,78 +0,0 @@
{
"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
View File

@@ -1,11 +0,0 @@
{
"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"
}
}

View File

@@ -0,0 +1,3 @@
# What is this?
This is the part of the docs dedicated for the team working on Nestri

View File

@@ -0,0 +1,27 @@
# 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

View File

@@ -0,0 +1,2 @@
title: 'Nestri Internals'
icon: heroicons-outline:bookmark-alt

View File

@@ -35,7 +35,9 @@
"@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.1.1",
"@fontsource-variable/bricolage-grotesque": "^5.0.1",
"@fontsource/geist-mono": "^5.1.0",
"@fontsource/geist-sans": "^5.1.0",
"@fontsource-variable/mona-sans": "^5.0.1",
"@modular-forms/qwik": "^0.29.0",
"@nestri/input": "*",
@@ -61,6 +63,7 @@
"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",

5478
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,29 @@
FROM docker.io/golang:1.23-alpine AS go-build
FROM docker.io/golang:1.24-alpine AS go-build
WORKDIR /builder
COPY packages/relay/ /builder/
RUN go build
FROM docker.io/golang:1.23-alpine
FROM docker.io/golang:1.24-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"]

View File

@@ -1,10 +1,18 @@
# 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_IMAGE} AS base-builder
FROM base AS base-builder
# Environment setup for Rust and Cargo
ENV CARGO_HOME=/usr/local/cargo \
@@ -14,9 +22,12 @@ 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 rust && \
pacman -Sy --noconfirm mold rustup && \
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
@@ -28,7 +39,8 @@ FROM base-builder AS nestri-server-deps
WORKDIR /builder
# Install build dependencies
RUN pacman -Sy --noconfirm meson pkgconf cmake git gcc make \
RUN --mount=type=cache,target=/var/cache/pacman/pkg \
pacman -Sy --noconfirm meson pkgconf cmake git gcc make \
gstreamer gst-plugins-base gst-plugins-good gst-plugin-rswebrtc
#--------------------------------------------------------------------
@@ -107,7 +119,7 @@ RUN --mount=type=cache,target=${CARGO_HOME}/registry \
#******************************************************************************
# Final Runtime Stage
#******************************************************************************
FROM ${BASE_IMAGE} AS runtime
FROM base AS runtime
### System Configuration ###
RUN sed -i \
@@ -117,27 +129,28 @@ RUN sed -i \
dirmngr </dev/null > /dev/null 2>&1
### Package Installation ###
RUN pacman --noconfirm -Sy && \
# Core system components
pacman -S --needed --noconfirm \
archlinux-keyring vulkan-intel lib32-vulkan-intel mesa \
# 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 \
steam steam-native-runtime \
sudo xorg-xwayland labwc wlr-randr mangohud \
sudo xorg-xwayland seatd libinput labwc wlr-randr mangohud \
libssh2 curl wget \
pipewire pipewire-pulse pipewire-alsa wireplumber \
noto-fonts-cjk supervisor jq chwd lshw pacman-contrib && \
# GStreamer stack
pacman -S --needed --noconfirm \
pacman -Sy --needed --noconfirm \
gstreamer gst-plugins-base gst-plugins-good \
gst-plugins-bad gst-plugin-pipewire \
gst-plugin-rswebrtc gst-plugin-rsrtp && \
gst-plugin-webrtchttp gst-plugin-rswebrtc gst-plugin-rsrtp \
gst-plugin-va gst-plugin-qsv && \
# Cleanup
paccache -rk1 && \
rm -rf /usr/share/{info,man,doc}/*
### Application Installation ###
ARG LUDUSAVI_VERSION="0.28.0"
RUN pacman -Sy --noconfirm --needed curl && \
curl -fsSL -o ludusavi.tar.gz \
RUN 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/ && \

View File

@@ -1,54 +1,82 @@
import { authFingerprintKey } from "./auth";
import { bus } from "./bus";
import { domain } from "./dns";
import { secret } from "./secrets"
// import { party } from "./party"
import { gpuTaskDefinition, ecsCluster } from "./cluster";
import { email } from "./email";
import { secret } from "./secret";
import { database } from "./database";
sst.Linkable.wrap(random.RandomString, (resource) => ({
properties: {
value: resource.result,
},
}));
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 kv = new sst.cloudflare.Kv("CloudflareAuthKV")
export const authFingerprintKey = new random.RandomString(
"AuthFingerprintKey",
{
length: 32,
},
);
export const auth = new sst.cloudflare.Worker("Auth", {
link: [
kv,
urls,
authFingerprintKey,
secret.InstantAdminToken,
secret.InstantAppId,
secret.LoopsApiKey,
secret.GithubClientID,
secret.GithubClientSecret,
secret.DiscordClientID,
secret.DiscordClientSecret,
],
handler: "./packages/functions/src/auth.ts",
url: true,
domain: "auth." + domain
});
export const auth = new sst.aws.Auth("Auth", {
issuer: {
timeout: "3 minutes",
handler: "./packages/functions/src/auth.handler",
link: [
bus,
email,
database,
authFingerprintKey,
secret.PolarSecret,
secret.GithubClientID,
secret.DiscordClientID,
secret.GithubClientSecret,
secret.DiscordClientSecret,
],
permissions: [
{
actions: ["ses:SendEmail"],
resources: ["*"],
},
],
},
domain: {
name: "auth." + domain,
dns: sst.cloudflare.dns(),
},
})
export const api = new sst.cloudflare.Worker("Api", {
export const apiFunction = new sst.aws.Function("ApiFn", {
handler: "packages/functions/src/api/index.handler",
link: [
bus,
urls,
ecsCluster,
gpuTaskDefinition,
authFingerprintKey,
secret.LoopsApiKey,
secret.InstantAppId,
secret.AwsAccessKey,
secret.AwsSecretKey,
secret.InstantAdminToken,
database,
secret.PolarSecret,
],
url: true,
handler: "./packages/functions/src/api/index.ts",
domain: "api." + domain
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(),
},
})
export const outputs = {
auth: auth.url,
api: api.url
}
api: api.url,
};

View File

@@ -1,12 +0,0 @@
export const authFingerprintKey = new random.RandomString(
"AuthFingerprintKey",
{
length: 32,
},
);
sst.Linkable.wrap(random.RandomString, (resource) => ({
properties: {
value: resource.result,
},
}));

20
infra/bus.ts Normal file
View File

@@ -0,0 +1,20 @@
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: ["*"],
},
],
});

View File

@@ -1,155 +0,0 @@
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}`

40
infra/database.ts Normal file
View File

@@ -0,0 +1,40 @@
//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,
},
});

6
infra/email.ts Normal file
View File

@@ -0,0 +1,6 @@
import { domain } from "./dns";
export const email = new sst.aws.Email("Mail",{
sender: domain,
dns: sst.cloudflare.dns(),
})

View File

@@ -1,33 +0,0 @@
// 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
// }

View File

@@ -1,179 +0,0 @@
// 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
// }

11
infra/secret.ts Normal file
View File

@@ -0,0 +1,11 @@
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);

View File

@@ -1,13 +0,0 @@
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);

View File

@@ -1,19 +0,0 @@
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);
});

View File

@@ -1,2 +0,0 @@
export const isPermanentStage =
$app.stage === "production" || $app.stage === "dev";

View File

@@ -1,4 +0,0 @@
// export const vpc = new sst.aws.Vpc("Vpc")
// export const storage = new sst.aws.Efs("GameStorage",{ vpc })
// //

View File

@@ -1,103 +0,0 @@
// 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 Normal file
View File

@@ -0,0 +1,20 @@
// 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,
},
})

View File

@@ -1,34 +1,31 @@
{
"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.145",
"@types/aws-lambda": "8.10.147",
"prettier": "^3.2.5",
"typescript": "^5.4.5"
},
"engines": {
"node": ">=18"
},
"packageManager": "bun@1.1.18",
"workspaces": [
"apps/*",
"packages/*"
],
"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"
},
"trustedDependencies": [
"core-js-pure",
"esbuild",
"workerd"
],
"workspaces": [
"apps/*",
"packages/*"
],
"dependencies": {
"sst": "3.6.27"
"sst": "3.9.1"
}
}
}

View File

@@ -0,0 +1,20 @@
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`,
},
});

View File

@@ -1,30 +0,0 @@
// 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;

View File

@@ -1,123 +0,0 @@
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;

View File

@@ -0,0 +1,37 @@
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");

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ALTER COLUMN "polar_customer_id" DROP NOT NULL;

View File

@@ -0,0 +1,281 @@
{
"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": {}
}
}

View File

@@ -0,0 +1,281 @@
{
"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": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"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
}
]
}

View File

@@ -3,6 +3,14 @@
"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"
},
@@ -10,6 +18,7 @@
"@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",
@@ -19,6 +28,13 @@
"zod-openapi": "^4.2.2"
},
"dependencies": {
"@instantdb/admin": "^0.17.7"
"@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"
}
}

View File

@@ -1,86 +1,92 @@
import { createContext } from "./context";
import { z } from "zod";
import { eq } from "./drizzle";
import { VisibleError } from "./error";
export interface UserActor {
type: "user";
properties: {
accessToken: string;
userID: string;
auth?:
| {
type: "personal";
token: string;
}
| {
type: "oauth";
clientID: string;
};
};
import { createContext } from "./context";
import { UserFlags, userTable } from "./user/user.sql";
import { useTransaction } from "./drizzle/transaction";
export const PublicActor = z.object({
type: z.literal("public"),
properties: z.object({}),
});
export type PublicActor = z.infer<typeof PublicActor>;
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 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 const SystemActor = z.object({
type: z.literal("system"),
properties: z.object({
teamID: z.string(),
}),
});
export type SystemActor = z.infer<typeof SystemActor>;
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() {
const actor = ActorContext.use();
if (actor.type === "user") return actor.properties.userID;
throw new VisibleError(
"unauthorized",
`You don't have permission to access this resource`,
);
}
export interface DeviceActor {
type: "device";
properties: {
teamSlug: string;
hostname: string;
auth?:
| {
type: "personal";
token: string;
}
| {
type: "oauth";
clientID: string;
};
};
}
export interface PublicActor {
type: "public";
properties: {};
}
type Actor = UserActor | PublicActor | DeviceActor;
export const ActorContext = createContext<Actor>();
export function useCurrentUser() {
const actor = ActorContext.use();
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}`);
}
export function assertActor<T extends Actor["type"]>(type: T) {
const actor = useActor();
if (actor.type !== type)
throw new VisibleError("auth", "actor.invalid", `Actor is not "${type}"`);
return actor as Extract<Actor, { type: T }>;
}
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",
);
}),
);
}

View File

@@ -1,90 +0,0 @@
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
})
}

View File

@@ -1,17 +1,17 @@
import { AsyncLocalStorage } from "node:async_hooks";
export function createContext<T>() {
export function createContext<T>(name: string) {
const storage = new AsyncLocalStorage<T>();
return {
use() {
const result = storage.getStore();
if (!result) {
throw new Error("No context available");
throw new Error("Context not provided: " + name);
}
return result;
},
with<R>(value: T, fn: () => R) {
return storage.run<R>(value, fn);
return storage.run<R, any[]>(value, fn);
},
};
}

View File

@@ -1,12 +0,0 @@
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

View File

@@ -0,0 +1,30 @@
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,
});

View File

@@ -0,0 +1,65 @@
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;
}
}

View File

@@ -0,0 +1,30 @@
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"),
};

View File

@@ -1,45 +1,36 @@
import { LoopsClient } from "loops";
import { Resource } from "sst/resource"
import { Resource } from "sst";
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
export namespace Email {
export const Client = () => new LoopsClient(Resource.LoopsApiKey.value);
export const Client = new SESv2Client({});
export async function send(
to: string,
body: string,
) {
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)
}
}
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}>`,
}),
);
}
}

View File

@@ -1,6 +1,5 @@
export class VisibleError extends Error {
constructor(
public kind: "input" | "auth",
public code: string,
public message: string,
) {

View File

@@ -0,0 +1,23 @@
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(),
};
},
});

View File

@@ -1,75 +1,33 @@
import { prefixes } from "./utils";
export module Examples {
export const Id = (prefix: keyof typeof prefixes) =>
`${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
export const User = {
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
id: Id("user"),
name: "John Doe",
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: "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'
id: Id("team"),
name: "John Does' Team",
slug: "john_doe",
}
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 Member = {
id: Id("member"),
email: "john@example.com",
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 Polar = {
teamID: Id("team"),
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
}
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'
}
}

View File

@@ -1,151 +0,0 @@
// 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
// })
// }

View File

@@ -1,83 +0,0 @@
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
})
}

View File

@@ -1,232 +0,0 @@
// 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
// })
// }

View File

@@ -0,0 +1,133 @@
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
};
}
}

View File

@@ -0,0 +1,18 @@
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),
],
);

View File

@@ -0,0 +1,169 @@
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,
};
}
}

View File

@@ -0,0 +1,22 @@
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),
})
)

View File

@@ -1,412 +0,0 @@
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
}
}
}

View File

@@ -1,251 +0,0 @@
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
}
})
}

View File

@@ -1,205 +0,0 @@
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]
})
}

View File

@@ -1,331 +0,0 @@
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
}
})
}

View File

@@ -1,164 +1,153 @@
import { z } from "zod";
import databaseClient from "../database"
import { fn } from "../utils";
import { groupBy, map, pipe, values } from "remeda"
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { Common } from "../common";
import { createID, fn } from "../utils";
import { Examples } from "../examples";
import { useCurrentUser } from "../actor";
import { id as createID } from "@instantdb/admin";
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";
export namespace Teams {
export module Team {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Team.id,
}),
name: z.string().openapi({
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",
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
})
})
.openapi({
ref: "Team",
description: "A group of users sharing the same machines for gaming.",
description: "Represents a team on Nestri",
example: Examples.Team,
});
export type Info = z.infer<typeof Info>;
export const list = async () => {
const db = databaseClient()
const user = useCurrentUser()
export const Events = {
Created: createEvent(
"team.created",
z.object({
teamID: z.string().nonempty(),
}),
),
};
const query = {
teams: {
$: {
where: {
members: user.id,
deletedAt: { $isNull: true }
}
},
}
export class TeamExistsError extends HTTPException {
constructor(slug: string) {
super(
400,
{ message: `There is already a team named "${slug}"`, }
);
}
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,
createdAt: group[0].createdAt,
updatedAt: group[0].updatedAt,
slug: group[0].slug,
//@ts-expect-error
owner: group[0].owner === user.id
}))
)
return result
}
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 })
export const fromSlug = fn(z.string(), async (slug) => {
const db = databaseClient()
if (!result.rowCount) throw new TeamExistsError(input.slug);
const query = {
teams: {
$: {
where: {
slug,
deletedAt: { $isNull: true }
}
},
}
}
await afterTx(() =>
withActor({ type: "system", properties: { teamID: id } }, () =>
bus.publish(Resource.Bus, Events.Created, {
teamID: id,
})
),
);
return id;
})
})
const res = await db.query(query)
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),
),
)
.execute()
.then((rows) => rows.at(0));
if (!row) return;
await tx
.update(teamTable)
.set({
timeDeleted: sql`now()`,
})
.where(eq(teamTable.id, row.teamID));
}),
);
const teams = res.teams
if (!teams || teams.length === 0) {
return null
}
export const list = fn(z.void(), () =>
useTransaction((tx) =>
tx
.select()
.from(teamTable)
.execute()
.then((rows) => rows.map(serialize)),
),
);
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
}))
)
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));
}),
);
return result[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 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()
await db.transact(db.tx.teams[id]!.update({
export function serialize(
input: typeof teamTable.$inferSelect,
): z.infer<typeof Info> {
return {
id: input.id,
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"
})
};
}
}

View File

@@ -0,0 +1,27 @@
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],
}),
];
}

View File

@@ -1,17 +0,0 @@
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
}

View File

@@ -1,37 +1,217 @@
import { z } from "zod";
import databaseClient from "../database"
import { fn } from "../utils";
import { Polar } from "../polar";
import { Team } from "../team";
import { bus } from "sst/aws/bus";
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,
}),
email: z.string().nullable().openapi({
description: "Email address of the user.",
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",
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: "A Nestri console user.",
description: "Represents a user on Nestri",
example: Examples.User,
});
export const fromEmail = fn(z.string(), async (email) => {
const db = databaseClient()
const res = await db.auth.getUser({ email })
return res
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 create = fn(z.string(), async (email) => {
const db = databaseClient()
const token = await db.auth.createToken(email)
export const create = fn(Info.omit({ polarCustomerID: true, discriminator: true }).partial({ avatarUrl: true, id: true }), async (input) => {
const userID = createID("user")
return token
//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
})
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))
);
}
}

View File

@@ -0,0 +1,27 @@
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),
],
);

View File

@@ -0,0 +1,11 @@
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("_");
}

View File

@@ -1 +1,2 @@
export * from "./fn"
export * from "./fn"
export * from "./id"

View File

@@ -14,6 +14,7 @@
"typescript": "^5.0.0"
},
"dependencies": {
"@openauthjs/openauth": "0.4.3",
"hono": "^4.6.15",
"hono-openapi": "^0.3.1",
"partysocket": "1.0.3"

View File

@@ -1,121 +0,0 @@
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]

View File

@@ -0,0 +1,64 @@
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);
},
)
}

View File

@@ -0,0 +1,69 @@
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,
// );
// },
);
}
};

View File

@@ -1,264 +0,0 @@
// 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);
// },
// );
// }

View File

@@ -1,79 +1,13 @@
import "zod-openapi/extend";
import { Resource } from "sst";
import { Hono } from "hono";
import { auth } from "./auth";
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 { subjects } from "../subjects";
import { SessionApi } from "./session";
// import { MachineApi } from "./machine";
import { AccountApi } from "./account";
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 { 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);
};
import { handle, streamHandle } from "hono/aws-lambda";
const app = new Hono();
@@ -85,14 +19,8 @@ app
.use(auth)
const routes = app
.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)
.get("/", (c) => c.text("Hello World!"))
.route("/account", AccountApi.route)
.onError((error, c) => {
console.warn(error);
if (error instanceof VisibleError) {
@@ -101,7 +29,7 @@ const routes = app
code: error.code,
message: error.message,
},
error.kind === "auth" ? 401 : 400,
400
);
}
if (error instanceof ZodError) {
@@ -151,9 +79,15 @@ 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: [] }],
security: [{ Bearer: [], TeamID:[] }],
servers: [
{ description: "Production", url: "https://api.nestri.io" },
],
@@ -162,4 +96,4 @@ app.get(
);
export type Routes = typeof routes;
export default app
export const handler = process.env.SST_DEV ? handle(app) : streamHandle(app);

View File

@@ -1,176 +0,0 @@
// 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);
// },
// );
// }

View File

@@ -1,175 +0,0 @@
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);
},
);
}

View File

@@ -1,130 +0,0 @@
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);
},
);
}

View File

@@ -1,277 +0,0 @@
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);
},
)
}

View File

@@ -1,238 +0,0 @@
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);
},
)
}

View File

@@ -1,177 +0,0 @@
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);
},
)
}

View File

@@ -1,40 +1,17 @@
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 { 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 { issuer } from "@openauthjs/openauth";
import { User } from "@nestri/core/user/index"
import { Email } from "@nestri/core/email/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 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>
}
import { type Provider } from "@openauthjs/openauth/provider/provider"
type OauthUser = {
primary: {
@@ -45,156 +22,176 @@ type OauthUser = {
avatar: any;
username: any;
}
export default {
async fetch(request: CFRequest, env: Env, ctx: ExecutionContext) {
// const location = `${request.cf.country},${request.cf.continent}`
return authorizer({
select: Select({
providers: {
device: {
hide: true,
},
},
}),
theme: {
title: "Nestri | Auth",
primary: "#FF4F01",
//TODO: Change this in prod
logo: "https://nestri.io/logo.webp",
favicon: "https://nestri.io/seo/favicon.ico",
background: {
light: "#f5f5f5 ",
dark: "#171717"
},
radius: "lg",
font: {
family: "Geist, sans-serif",
},
css: `
const app = issuer({
select: Select({
providers: {
device: {
hide: true,
},
},
}),
theme: {
title: "Nestri | Auth",
primary: "#FF4F01",
//TODO: Change this in prod
logo: "https://nestri.io/logo.webp",
favicon: "https://nestri.io/seo/favicon.ico",
background: {
light: "#f5f5f5 ",
dark: "#171717"
},
radius: "lg",
font: {
family: "Geist, sans-serif",
},
css: `
@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({
clientID: Resource.GithubClientID.value,
clientSecret: Resource.GithubClientSecret.value,
scopes: ["user:email"]
}),
discord: DiscordAdapter({
clientID: Resource.DiscordClientID.value,
clientSecret: Resource.DiscordClientSecret.value,
scopes: ["email", "identify"]
}),
password: PasswordAdapter(
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}`,
// )
},
}),
subjects,
providers: {
github: GithubAdapter({
clientID: Resource.GithubClientID.value,
clientSecret: Resource.GithubClientSecret.value,
scopes: ["user:email"]
}),
discord: DiscordAdapter({
clientID: Resource.DiscordClientID.value,
clientSecret: Resource.DiscordClientSecret.value,
scopes: ["email", "identify"]
}),
password: PasswordAdapter(
PasswordUI({
sendCode: async (email, code) => {
console.log("email & code:", email, code)
await Email.send(email, code)
},
}),
),
device: {
type: "device",
async client(input) {
if (input.clientSecret !== Resource.AuthFingerprintKey.value) {
throw new Error("Invalid authorization token");
}
const teamSlug = input.params.team;
if (!teamSlug) {
throw new Error("Team slug is required");
}
const hostname = input.params.hostname;
if (!hostname) {
throw new Error("Hostname is required");
}
return {
hostname,
teamSlug
};
},
init() { }
} as Adapter<{ teamSlug: string; hostname: string; }>,
},
allow: async (input) => {
const url = new URL(input.redirectURI);
const hostname = url.hostname;
if (hostname.endsWith("nestri.io")) return true;
if (hostname === "localhost") return true;
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 })
return await ctx.subject("device", {
teamSlug: value.teamSlug,
hostname: value.hostname,
})
}
),
device: {
type: "device",
async client(input) {
if (input.clientSecret !== Resource.AuthFingerprintKey.value) {
throw new Error("Invalid authorization token");
}
const teamSlug = input.params.team;
if (!teamSlug) {
throw new Error("Team slug is required");
}
if (value.provider === "password") {
const email = value.email
const username = value.username
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 })
}
const hostname = input.params.hostname;
if (!hostname) {
throw new Error("Hostname is required");
}
return await ctx.subject("user", {
accessToken: token,
userID: usr.id,
return {
hostname,
teamSlug
};
},
init() { }
} as Provider<{ teamSlug: string; hostname: string; }>,
},
allow: async (input) => {
const url = new URL(input.redirectURI);
const hostname = url.hostname;
if (hostname.endsWith("nestri.io")) return true;
if (hostname === "localhost") return true;
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 })
// 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
});
}
}
let user = undefined as OauthUser | undefined;
if (value.provider === "github") {
const access = value.tokenset.access;
user = await handleGithub(access)
}
if (value.provider === "discord") {
const access = value.tokenset.access
user = await handleDiscord(access)
}
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
});
}
let user = undefined as OauthUser | undefined;
} catch (error) {
console.error("error registering the user", error)
}
if (value.provider === "github") {
const access = value.tokenset.access;
user = await handleGithub(access)
}
}
if (value.provider === "discord") {
const access = value.tokenset.access
user = await handleDiscord(access)
}
throw new Error("Something went seriously wrong");
},
}).use(logger())
if (user) {
try {
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)
}
}
throw new Error("Something went seriously wrong");
},
}).fetch(request, env, ctx)
}
}
export const handler = handle(app)

View File

@@ -0,0 +1,36 @@
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;
}
}
},
);

View File

@@ -1,14 +1,14 @@
import * as v from "valibot"
import { Subscription } from "./type"
import { createSubjects } from "@openauthjs/openauth"
import { createSubjects } from "@openauthjs/openauth/subject"
export const subjects = createSubjects({
user: v.object({
accessToken: v.string(),
userID: v.string()
email: v.string(),
userID: v.string(),
}),
device: v.object({
teamSlug: v.string(),
hostname: v.string(),
})
// device: v.object({
// teamSlug: v.string(),
// hostname: v.string(),
// })
})

View File

@@ -2,7 +2,7 @@
import { Layout } from "../base"
import { OauthError } from "@openauthjs/openauth/error"
import { getRelativeUrl } from "@openauthjs/openauth/util"
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
import { type Provider } from "@openauthjs/openauth/provider/provider"
export interface Oauth2Config {
type?: string
@@ -32,7 +32,7 @@ interface AdapterState {
export function Oauth2Adapter(
config: Oauth2Config,
): Adapter<{ tokenset: Oauth2Token; clientID: string }> {
): Provider<{ tokenset: Oauth2Token; clientID: string }> {
const query = config.query || {}
return {
type: config.type || "oauth2",

View File

@@ -1,7 +1,6 @@
import { Profiles } from "@nestri/core/profile/index"
import { UnknownStateError } from "@openauthjs/openauth/error"
// import { UnknownStateError } from "@openauthjs/openauth/error"
import { Storage } from "@openauthjs/openauth/storage/storage"
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
import { type Provider } from "@openauthjs/openauth/provider/provider"
import { generateUnbiasedDigits, timingSafeCompare } from "@openauthjs/openauth/random"
export interface PasswordHasher<T> {
@@ -309,7 +308,7 @@ export function PasswordAdapter(config: PasswordConfig) {
return transition({ type: "start", redirect: adapter.redirect })
})
},
} satisfies Adapter<{ email: string; username?:string }>
} satisfies Provider<{ email: string; username?:string }>
}
import * as jose from "jose"
@@ -378,6 +377,7 @@ 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

View File

@@ -1,4 +1,6 @@
export const handleGithub = async (accessKey: string) => {
console.log("acceskey", accessKey)
const headers = {
Authorization: `token ${accessKey}`,
Accept: "application/vnd.github.v3+json",

View File

@@ -6,17 +6,34 @@
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
}
"AwsAccessKey": {
"type": "sst.sst.Secret"
"value": string
"Bus": {
"arn": string
"name": string
"type": "sst.aws.Bus"
}
"AwsSecretKey": {
"type": "sst.sst.Secret"
"value": string
"Database": {
"host": string
"name": string
"password": string
"type": "sst.sst.Linkable"
"user": string
}
"DiscordClientID": {
"type": "sst.sst.Secret"
@@ -34,40 +51,25 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"InstantAdminToken": {
"Mail": {
"configSet": string
"sender": string
"type": "sst.aws.Email"
}
"PolarSecret": {
"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"
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"Auth": cloudflare.Service
"CloudflareAuthKV": cloudflare.KVNamespace
"Web": {
"type": "sst.aws.StaticSite"
"url": string
}
}
}

View File

@@ -1,32 +1,32 @@
module relay
go 1.23
go 1.24
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.8
google.golang.org/protobuf v1.36.4
github.com/pion/webrtc/v4 v4.0.12
google.golang.org/protobuf v1.36.5
)
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.11 // indirect
github.com/pion/sctp v1.8.35 // indirect
github.com/pion/rtp v1.8.12 // indirect
github.com/pion/sctp v1.8.36 // 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.32.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // 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
)

View File

@@ -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.5 h1:6awVfa1jg9YsI9/Lep4TG/o3kwS1Oayr5b8xz50ibJ8=
github.com/pion/ice/v4 v4.0.5/go.mod h1:JJaoEIxUIlGDA9gaRZbwXYqI3j6VG/QchpjX+QmwN6A=
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/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.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/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/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.8 h1:T1ZmnT9qxIJIt4d8XoiMOBrTClGHDDXNg9e/fh018Qc=
github.com/pion/webrtc/v4 v4.0.8/go.mod h1:HHBeUVBAC+j4ZFnYhovEFStF02Arb1EyD4G7e7HBTJw=
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/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.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/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/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.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,6 +1,7 @@
package relay
import (
"github.com/pion/ice/v4"
"github.com/pion/interceptor"
"github.com/pion/webrtc/v4"
"log"
@@ -38,7 +39,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
}
}
@@ -58,12 +59,28 @@ func InitWebRTCAPI() error {
// New in v4, reduces CPU usage and latency when enabled
settingEngine.EnableSCTPZeroChecksum(true)
// Set the UDP port range used by WebRTC
err = settingEngine.SetEphemeralUDPPortRange(uint16(flags.WebRTCUDPStart), uint16(flags.WebRTCUDPEnd))
if err != nil {
return err
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))
@@ -88,7 +105,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())
}

View File

@@ -25,7 +25,7 @@ func participantHandler(participant *Participant, room *Room) {
}
// Data channel settings
settingOrdered := false
settingOrdered := true
settingMaxRetransmits := uint16(0)
dc, err := participant.PeerConnection.CreateDataChannel("data", &webrtc.DataChannelInit{
Ordered: &settingOrdered,
@@ -75,13 +75,10 @@ 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)
}
}
if err = room.DataChannel.SendBinary(data); err != nil {
log.Printf("Failed to send input message to room: '%s' - reason: %s\n", room.Name, err)
}
}
})

View File

@@ -2,22 +2,28 @@ package relay
import (
"flag"
"github.com/pion/webrtc/v4"
"log"
"net"
"os"
"strconv"
"github.com/pion/webrtc/v4"
"strings"
)
var globalFlags *Flags
type Flags struct {
Verbose bool
Debug bool
EndpointPort int
WebRTCUDPStart int
WebRTCUDPEnd int
STUNServer string
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
}
func (flags *Flags) DebugLog() {
@@ -28,6 +34,13 @@ 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 {
@@ -66,6 +79,13 @@ 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()
@@ -75,8 +95,44 @@ 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 ""
}

View File

@@ -2,6 +2,7 @@ package relay
import (
"encoding/json"
"errors"
"github.com/gorilla/websocket"
"log"
"net/http"
@@ -10,7 +11,7 @@ import (
var httpMux *http.ServeMux
func InitHTTPEndpoint() {
func InitHTTPEndpoint() error {
// Create HTTP mux which serves our WS endpoint
httpMux = http.NewServeMux()
@@ -20,15 +21,30 @@ func InitHTTPEndpoint() {
// Get our serving port
port := GetFlags().EndpointPort
tlsCert := GetFlags().TLSCert
tlsKey := GetFlags().TLSKey
// Log and start the endpoint server
log.Println("Starting HTTP endpoint server on :", strconv.Itoa(port))
go func() {
log.Fatal((&http.Server{
Handler: httpMux,
Addr: ":" + strconv.Itoa(port),
}).ListenAndServe())
}()
if len(tlsCert) <= 0 && len(tlsKey) <= 0 {
log.Println("Starting HTTP endpoint server on :", strconv.Itoa(port))
go func() {
log.Fatal((&http.Server{
Handler: httpMux,
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

View File

@@ -31,29 +31,42 @@ if [ ! -e "$PIPEWIRE_SOCKET" ]; then
exit 1
fi
echo "Detecting GPU vendor and installing necessary GStreamer plugins..."
echo "Detecting GPU vendor..."
source /etc/nestri/gpu_helpers.sh
get_gpu_info
# Check vendors in priority order
# Check for NVIDIA so we can apply a workaround
if [[ -n "${vendor_devices[nvidia]:-}" ]]; then
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 "Unknown GPU vendor. No additional packages will be installed"
fi
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 ' ')
else
echo "Failed to determine NVIDIA driver version. Exiting."
exit 1
fi
# Clean up remainders
echo "Cleaning up old package cache..."
paccache -rk1
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
echo "Switching to nestri user for application startup..."
exec sudo -E -u nestri /etc/nestri/entrypoint_nestri.sh

View File

@@ -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="/run/user/${UID}/wayland-1"
WAYLAND_SOCKET="${XDG_RUNTIME_DIR}/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,6 +69,11 @@ 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 &
@@ -76,11 +81,11 @@ start_compositor() {
# Wait for compositor to initialize
echo "Waiting for compositor to initialize..."
COMPOSITOR_SOCKET="/run/user/${UID}/wayland-0"
COMPOSITOR_SOCKET="${XDG_RUNTIME_DIR}/wayland-0"
for _ in {1..15}; do
if [ -e "$COMPOSITOR_SOCKET" ]; then
echo "compositor is initialized, wayland-0 output ready."
sleep 1 # necessary sleep - reduces chance that non-ready socket is used
sleep 3 # necessary sleep - reduces chance that non-ready socket is used
start_wlr_randr
return
fi
@@ -101,8 +106,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. Skipping wlr-randr."
return
echo "Error: No enabled outputs detected, exiting."
exit 1
fi
# Retry logic for wlr-randr
@@ -111,12 +116,13 @@ 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. Moving on without resolution setup."
return
echo "Max retries reached for wlr-randr, exiting."
exit 1
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

View File

@@ -3,11 +3,9 @@ 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/

16
packages/scripts/src/psql.ts Executable file
View File

@@ -0,0 +1,16 @@
#!/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",
},
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package]
name = "nestri-server"
version = "0.1.0-alpha.2"
edition = "2021"
edition = "2024"
[[bin]]
name = "nestri-server"

Some files were not shown because too many files have changed in this diff Show More