mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-10 07:45:36 +02:00
⭐ 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 -->
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
NEON_API_KEY=
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ node_modules
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.sst
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "unifiedjs.vscode-mdx"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
24
.vscode/launch.json
vendored
24
.vscode/launch.json
vendored
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
36
.vscode/qwik-city.code-snippets
vendored
36
.vscode/qwik-city.code-snippets
vendored
@@ -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",
|
||||
"});",
|
||||
],
|
||||
},
|
||||
}
|
||||
78
.vscode/qwik.code-snippets
vendored
78
.vscode/qwik.code-snippets
vendored
@@ -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
11
.vscode/settings.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
3
apps/docs/content/4.nestri-internal/1.what-is-this.md
Normal file
3
apps/docs/content/4.nestri-internal/1.what-is-this.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# What is this?
|
||||
|
||||
This is the part of the docs dedicated for the team working on Nestri
|
||||
27
apps/docs/content/4.nestri-internal/2.setup.md
Normal file
27
apps/docs/content/4.nestri-internal/2.setup.md
Normal 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
|
||||
2
apps/docs/content/4.nestri-internal/_dir.yml
Normal file
2
apps/docs/content/4.nestri-internal/_dir.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
title: 'Nestri Internals'
|
||||
icon: heroicons-outline:bookmark-alt
|
||||
@@ -61,6 +61,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",
|
||||
|
||||
100
infra/api.ts
100
infra/api.ts
@@ -1,54 +1,82 @@
|
||||
import { authFingerprintKey } from "./auth";
|
||||
import { bus } from "./bus";
|
||||
import { database } from "./database";
|
||||
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";
|
||||
|
||||
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,
|
||||
};
|
||||
17
infra/bus.ts
Normal file
17
infra/bus.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { database } from "./database";
|
||||
import { email } from "./email";
|
||||
import { allSecrets } from "./secret";
|
||||
|
||||
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: ["*"],
|
||||
},
|
||||
],
|
||||
});
|
||||
39
infra/database.ts
Normal file
39
infra/database.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
const dbProject = new neon.Project("Nestri", {
|
||||
historyRetentionSeconds: 86400,
|
||||
// name:"Nestri"
|
||||
})
|
||||
|
||||
const dbBranchId = $app.stage !== "production" ?
|
||||
new neon.Branch("DatabaseBranch", {
|
||||
parentId: dbProject.defaultBranchId,
|
||||
projectId: dbProject.id,
|
||||
name: $app.stage,
|
||||
}).id : dbProject.defaultBranchId
|
||||
|
||||
const dbEndpoint = new neon.Endpoint("NestriEndpoint", {
|
||||
projectId: dbProject.id,
|
||||
branchId: dbBranchId
|
||||
})
|
||||
|
||||
const dbRole = new neon.Role("AdminRole", {
|
||||
name: "admin",
|
||||
branchId: dbBranchId,
|
||||
projectId: dbProject.id,
|
||||
})
|
||||
|
||||
const db = new neon.Database("NestriDatabase", {
|
||||
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
6
infra/email.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { domain } from "./dns";
|
||||
|
||||
export const email = new sst.aws.Email("Mail",{
|
||||
sender: domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
})
|
||||
11
infra/secret.ts
Normal file
11
infra/secret.ts
Normal 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);
|
||||
20
infra/www.ts
Normal file
20
infra/www.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// This is the website part where people play and connect
|
||||
import { auth, api } from "./api";
|
||||
import { domain } from "./dns";
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
54
infra:old/api.ts
Normal file
54
infra:old/api.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { authFingerprintKey } from "./auth";
|
||||
import { domain } from "./dns";
|
||||
import { secret } from "./secrets"
|
||||
// import { party } from "./party"
|
||||
import { gpuTaskDefinition, ecsCluster } from "./cluster";
|
||||
|
||||
export const urls = new sst.Linkable("Urls", {
|
||||
properties: {
|
||||
api: "https://api." + domain,
|
||||
auth: "https://auth." + domain,
|
||||
},
|
||||
});
|
||||
|
||||
export const kv = new sst.cloudflare.Kv("CloudflareAuthKV")
|
||||
|
||||
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 api = new sst.cloudflare.Worker("Api", {
|
||||
link: [
|
||||
urls,
|
||||
ecsCluster,
|
||||
gpuTaskDefinition,
|
||||
authFingerprintKey,
|
||||
secret.LoopsApiKey,
|
||||
secret.InstantAppId,
|
||||
secret.AwsAccessKey,
|
||||
secret.AwsSecretKey,
|
||||
secret.InstantAdminToken,
|
||||
],
|
||||
url: true,
|
||||
handler: "./packages/functions/src/api/index.ts",
|
||||
domain: "api." + domain
|
||||
})
|
||||
|
||||
export const outputs = {
|
||||
auth: auth.url,
|
||||
api: api.url
|
||||
}
|
||||
9
infra:old/dns.ts
Normal file
9
infra:old/dns.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const domain =
|
||||
{
|
||||
production: "nestri.io",
|
||||
dev: "dev.nestri.io",
|
||||
}[$app.stage] || $app.stage + ".dev.nestri.io";
|
||||
|
||||
export const zone = cloudflare.getZoneOutput({
|
||||
name: "nestri.io",
|
||||
});
|
||||
29
old.sst.config.ts
Normal file
29
old.sst.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/// <reference path="./.sst/platform/config.d.ts" />
|
||||
import { readdirSync } from "fs";
|
||||
export default $config({
|
||||
app(input) {
|
||||
return {
|
||||
name: "nestri",
|
||||
removal: input?.stage === "production" ? "retain" : "remove",
|
||||
home: "cloudflare",
|
||||
providers: {
|
||||
cloudflare: "5.37.1",
|
||||
docker: "4.5.5",
|
||||
"@pulumi/command": "1.0.1",
|
||||
random: "4.16.8",
|
||||
aws: "6.67.0",
|
||||
tls: "5.1.0",
|
||||
command: "0.0.1-testwindows.signing",
|
||||
awsx: "2.21.0",
|
||||
},
|
||||
};
|
||||
},
|
||||
async run() {
|
||||
const outputs = {};
|
||||
for (const value of readdirSync("./infra/")) {
|
||||
const result = await import("./infra/" + value);
|
||||
if (result.outputs) Object.assign(outputs, result.outputs);
|
||||
}
|
||||
return outputs;
|
||||
},
|
||||
});
|
||||
10
package.json
10
package.json
@@ -4,14 +4,14 @@
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"dev": "turbo dev",
|
||||
"sst": "sst dev",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"lint": "turbo lint"
|
||||
"lint": "turbo lint",
|
||||
"sso": "aws sso login --sso-session=nestri --no-browser --use-device-code"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -29,6 +29,6 @@
|
||||
"workerd"
|
||||
],
|
||||
"dependencies": {
|
||||
"sst": "3.6.27"
|
||||
"sst": "3.9.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
packages/core/drizzle.config.ts
Normal file
12
packages/core/drizzle.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Resource } from "sst";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/**/*.sql.ts",
|
||||
out: "./migrations",
|
||||
dialect: "postgresql",
|
||||
verbose: true,
|
||||
dbCredentials: {
|
||||
url: `postgresql://${Resource.Database.user}:${Resource.Database.password}@${Resource.Database.host}/${Resource.Database.name}?sslmode=require`,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
37
packages/core/migrations/0000_wise_black_widow.sql
Normal file
37
packages/core/migrations/0000_wise_black_widow.sql
Normal 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");
|
||||
1
packages/core/migrations/0001_flaky_tomorrow_man.sql
Normal file
1
packages/core/migrations/0001_flaky_tomorrow_man.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ALTER COLUMN "polar_customer_id" DROP NOT NULL;
|
||||
281
packages/core/migrations/meta/0000_snapshot.json
Normal file
281
packages/core/migrations/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
281
packages/core/migrations/meta/0001_snapshot.json
Normal file
281
packages/core/migrations/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
20
packages/core/migrations/meta/_journal.json
Normal file
20
packages/core/migrations/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,12 @@
|
||||
"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/openevent": "^0.0.27",
|
||||
"@polar-sh/sdk": "^0.26.1",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"ws": "^8.18.1"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
22
packages/core/src/drizzle/index.ts
Normal file
22
packages/core/src/drizzle/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
|
||||
const client = new Pool({ connectionString: `postgres://${Resource.Database.user}:${Resource.Database.password}@${Resource.Database.host}/${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,
|
||||
});
|
||||
65
packages/core/src/drizzle/transaction.ts
Normal file
65
packages/core/src/drizzle/transaction.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
30
packages/core/src/drizzle/types.ts
Normal file
30
packages/core/src/drizzle/types.ts
Normal 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"),
|
||||
};
|
||||
@@ -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}>`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export class VisibleError extends Error {
|
||||
constructor(
|
||||
public kind: "input" | "auth",
|
||||
public code: string,
|
||||
public message: string,
|
||||
) {
|
||||
|
||||
23
packages/core/src/event.ts
Normal file
23
packages/core/src/event.ts
Normal 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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,75 +1,29 @@
|
||||
import { teamID } from "./drizzle/types";
|
||||
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 Member = {
|
||||
id: Id("member"),
|
||||
email: "john@example.com",
|
||||
teamID: Id("team"),
|
||||
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
|
||||
}
|
||||
|
||||
export const Machine = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
hostname: "DESKTOP-EUO8VSF",
|
||||
fingerprint: "fc27f428f9ca47d4b41b70889ae0c62090",
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
deletedAt: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Instance = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
hostname: "a955e059f05d",
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
lastActive: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Game = {
|
||||
id: '0bfcb712-df13-4454-81a8-fbee66eddca4',
|
||||
name: "Control Ultimate Edition",
|
||||
steamID: 870780,
|
||||
}
|
||||
|
||||
export const Session = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
public: true,
|
||||
startedAt: '2025-01-04T11:56:23.902Z',
|
||||
endedAt: '2025-01-04T12:36:23.902Z'
|
||||
}
|
||||
}
|
||||
133
packages/core/src/member/index.ts
Normal file
133
packages/core/src/member/index.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
18
packages/core/src/member/member.sql.ts
Normal file
18
packages/core/src/member/member.sql.ts
Normal 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),
|
||||
],
|
||||
);
|
||||
8
packages/core/src/polar.ts
Normal file
8
packages/core/src/polar.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Resource } from "sst";
|
||||
import { Polar as PolarSdk } from "@polar-sh/sdk";
|
||||
|
||||
const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" });
|
||||
|
||||
export module Polar {
|
||||
export const client = polar;
|
||||
}
|
||||
@@ -1,164 +1,152 @@
|
||||
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 { VisibleError } from "../error";
|
||||
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 } from "../actor";
|
||||
import { and, eq, sql } from "../drizzle";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
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 WorkspaceExistsError extends VisibleError {
|
||||
constructor(slug: string) {
|
||||
super(
|
||||
"team.slug_exists",
|
||||
`there is already a workspace 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()
|
||||
.returning({ insertedID: teamTable.id })
|
||||
|
||||
export const fromSlug = fn(z.string(), async (slug) => {
|
||||
const db = databaseClient()
|
||||
if (result.length === 0) throw new WorkspaceExistsError(input.slug);
|
||||
|
||||
const query = {
|
||||
teams: {
|
||||
$: {
|
||||
where: {
|
||||
slug,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
await afterTx(() =>
|
||||
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"
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
27
packages/core/src/team/team.sql.ts
Normal file
27
packages/core/src/team/team.sql.ts
Normal 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],
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,37 +1,219 @@
|
||||
import { z } from "zod";
|
||||
import databaseClient from "../database"
|
||||
import { fn } from "../utils";
|
||||
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";
|
||||
import { Team } from "../team";
|
||||
|
||||
|
||||
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.client.customers.create({
|
||||
// email: input.email,
|
||||
// metadata: {
|
||||
// userID,
|
||||
// },
|
||||
// });
|
||||
|
||||
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),
|
||||
})
|
||||
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))
|
||||
);
|
||||
}
|
||||
}
|
||||
27
packages/core/src/user/user.sql.ts
Normal file
27
packages/core/src/user/user.sql.ts
Normal 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),
|
||||
],
|
||||
);
|
||||
11
packages/core/src/utils/id.ts
Normal file
11
packages/core/src/utils/id.ts
Normal 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("_");
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./fn"
|
||||
export * from "./fn"
|
||||
export * from "./id"
|
||||
86
packages/core/src:old/actor.ts
Normal file
86
packages/core/src:old/actor.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createContext } from "../src/context";
|
||||
import { VisibleError } from "./error";
|
||||
|
||||
export interface UserActor {
|
||||
type: "user";
|
||||
properties: {
|
||||
accessToken: string;
|
||||
userID: string;
|
||||
auth?:
|
||||
| {
|
||||
type: "personal";
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
type: "oauth";
|
||||
clientID: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
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 VisibleError("auth", "actor.invalid", `Actor is not "${type}"`);
|
||||
return actor as Extract<Actor, { type: T }>;
|
||||
}
|
||||
7
packages/core/src:old/common.ts
Normal file
7
packages/core/src:old/common.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import "zod-openapi/extend";
|
||||
|
||||
export module Common {
|
||||
export const IdDescription = `Unique object identifier.
|
||||
The format and length of IDs may change over time.`;
|
||||
}
|
||||
45
packages/core/src:old/email/index.ts
Normal file
45
packages/core/src:old/email/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { LoopsClient } from "loops";
|
||||
import { Resource } from "sst/resource"
|
||||
export namespace Email {
|
||||
export const Client = () => new LoopsClient(Resource.LoopsApiKey.value);
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
9
packages/core/src:old/error.ts
Normal file
9
packages/core/src:old/error.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export class VisibleError extends Error {
|
||||
constructor(
|
||||
public kind: "input" | "auth",
|
||||
public code: string,
|
||||
public message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
75
packages/core/src:old/examples.ts
Normal file
75
packages/core/src:old/examples.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export module Examples {
|
||||
|
||||
export const User = {
|
||||
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
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 Instance = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
hostname: "a955e059f05d",
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
lastActive: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Game = {
|
||||
id: '0bfcb712-df13-4454-81a8-fbee66eddca4',
|
||||
name: "Control Ultimate Edition",
|
||||
steamID: 870780,
|
||||
}
|
||||
|
||||
export const Session = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
public: true,
|
||||
startedAt: '2025-01-04T11:56:23.902Z',
|
||||
endedAt: '2025-01-04T12:36:23.902Z'
|
||||
}
|
||||
}
|
||||
164
packages/core/src:old/team/index.ts
Normal file
164
packages/core/src:old/team/index.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
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";
|
||||
|
||||
export namespace Teams {
|
||||
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",
|
||||
example: Examples.Team.slug
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Team",
|
||||
description: "A group of users sharing the same machines for gaming.",
|
||||
example: Examples.Team,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async () => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
|
||||
const query = {
|
||||
teams: {
|
||||
$: {
|
||||
where: {
|
||||
members: user.id,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const teams = res.teams
|
||||
if (!teams || teams.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
teams,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
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 fromSlug = fn(z.string(), async (slug) => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
teams: {
|
||||
$: {
|
||||
where: {
|
||||
slug,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const teams = res.teams
|
||||
if (!teams || teams.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
teams,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
slug: group[0].slug,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
// owner: group[0].owner === user.id
|
||||
}))
|
||||
)
|
||||
|
||||
return result[0]
|
||||
})
|
||||
|
||||
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({
|
||||
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"
|
||||
})
|
||||
|
||||
}
|
||||
37
packages/core/src:old/user/index.ts
Normal file
37
packages/core/src:old/user/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
import databaseClient from "../database"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
|
||||
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.",
|
||||
example: Examples.User.email,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "User",
|
||||
description: "A Nestri console user.",
|
||||
example: Examples.User,
|
||||
});
|
||||
|
||||
export const fromEmail = fn(z.string(), async (email) => {
|
||||
const db = databaseClient()
|
||||
const res = await db.auth.getUser({ email })
|
||||
return res
|
||||
})
|
||||
|
||||
export const create = fn(z.string(), async (email) => {
|
||||
const db = databaseClient()
|
||||
const token = await db.auth.createToken(email)
|
||||
|
||||
return token
|
||||
})
|
||||
}
|
||||
27
packages/core/src:old/utils/fn.ts
Normal file
27
packages/core/src:old/utils/fn.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ZodSchema, z } from "zod";
|
||||
|
||||
export function fn<
|
||||
Arg1 extends ZodSchema,
|
||||
Callback extends (arg1: z.output<Arg1>) => any,
|
||||
>(arg1: Arg1, cb: Callback) {
|
||||
const result = function (input: z.input<typeof arg1>): ReturnType<Callback> {
|
||||
const parsed = arg1.parse(input);
|
||||
return cb.apply(cb, [parsed as any]);
|
||||
};
|
||||
result.schema = arg1;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function doubleFn<
|
||||
Arg1 extends ZodSchema,
|
||||
Arg2 extends ZodSchema,
|
||||
Callback extends (arg1: z.output<Arg1>, arg2: z.output<Arg2>) => any,
|
||||
>(arg1: Arg1, arg2: Arg2, cb: Callback) {
|
||||
const result = function (input: z.input<typeof arg1>, input2: z.input<typeof arg2>): ReturnType<Callback> {
|
||||
const parsed = arg1.parse(input);
|
||||
const parsed2 = arg2.parse(input2);
|
||||
return cb.apply(cb, [parsed as any, parsed2 as any]);
|
||||
};
|
||||
result.schema = arg1;
|
||||
return result;
|
||||
}
|
||||
9
packages/core/src:old/utils/id.ts
Normal file
9
packages/core/src:old/utils/id.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ulid } from "ulid";
|
||||
|
||||
export const prefixes = {
|
||||
user: "usr",
|
||||
} as const;
|
||||
|
||||
export function createID(prefix: keyof typeof prefixes): string {
|
||||
return [prefixes[prefix], ulid()].join("_");
|
||||
}
|
||||
2
packages/core/src:old/utils/index.ts
Normal file
2
packages/core/src:old/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./fn"
|
||||
export * from "./id"
|
||||
@@ -14,6 +14,7 @@
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "^0.3.9",
|
||||
"hono": "^4.6.15",
|
||||
"hono-openapi": "^0.3.1",
|
||||
"partysocket": "1.0.3"
|
||||
|
||||
@@ -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]
|
||||
62
packages/functions/src/api/account.ts
Normal file
62
packages/functions/src/api/account.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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 = await User.fromID(actor.properties.userID)
|
||||
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,
|
||||
avatarUrl,
|
||||
discriminator,
|
||||
polarCustomerID,
|
||||
teams: await User.teams(),
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
)
|
||||
}
|
||||
69
packages/functions/src/api/auth.ts
Normal file
69
packages/functions/src/api/auth.ts
Normal 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,
|
||||
// );
|
||||
// },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
36
packages/functions/src/event/event.ts
Normal file
36
packages/functions/src/event/event.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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(),
|
||||
// })
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
66
packages/functions/sst-env.d.ts
vendored
66
packages/functions/sst-env.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
packages/scripts/src/psql.ts
Executable file
16
packages/scripts/src/psql.ts
Executable 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",
|
||||
},
|
||||
);
|
||||
@@ -5,7 +5,7 @@ module.exports = {
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:qwik/recommended",
|
||||
|
||||
175
packages/www/.gitignore
vendored
Normal file
175
packages/www/.gitignore
vendored
Normal file
@@ -0,0 +1,175 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Caches
|
||||
|
||||
.cache
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
15
packages/www/README.md
Normal file
15
packages/www/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# @console/www
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.1.34. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user