diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..e782b56c --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +CLOUDFLARE_API_TOKEN= +NEON_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 492013e5..078e0280 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ node_modules # Local env files .env .env.local +.env.sst .env.development.local .env.test.local .env.production.local diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index c53b492d..00000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "recommendations": ["dbaeumer.vscode-eslint", "unifiedjs.vscode-mdx"], - "unwantedRecommendations": [] -} diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index e684cc84..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -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": ["/**"], - "cwd": "${workspaceFolder}", - "program": "${workspaceFolder}/node_modules/vite/bin/vite.js", - "args": ["--mode", "ssr", "--force"] - } - ] -} diff --git a/.vscode/qwik-city.code-snippets b/.vscode/qwik-city.code-snippets deleted file mode 100644 index 878fcf68..00000000 --- a/.vscode/qwik-city.code-snippets +++ /dev/null @@ -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", - "});", - ], - }, -} diff --git a/.vscode/qwik.code-snippets b/.vscode/qwik.code-snippets deleted file mode 100644 index 62edc825..00000000 --- a/.vscode/qwik.code-snippets +++ /dev/null @@ -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", - "});", - ], - }, - "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", - " ", - " );", - "});", - ], - }, - "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", - "});", - "", - ], - }, -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 18a64e7e..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "material-icon-theme.activeIconPack": "qwik", - "emmet.includeLanguages": { - "typescriptreact": "html" - }, - "emmet.preferences": { - // to ensure closing tags are used (e.g. not just like in HTML) - // https://github.com/microsoft/vscode/commit/083bf9020407ea5a91199eb1f0b373859df8d600#diff-88456bc9b7caa2f8126aea0107b4671db0f094961aaf39a7c689f890e23aaaba - "output.selfClosingStyle": "xhtml" - } -} diff --git a/apps/docs/content/4.nestri-internal/1.what-is-this.md b/apps/docs/content/4.nestri-internal/1.what-is-this.md new file mode 100644 index 00000000..6dcf7f46 --- /dev/null +++ b/apps/docs/content/4.nestri-internal/1.what-is-this.md @@ -0,0 +1,3 @@ +# What is this? + +This is the part of the docs dedicated for the team working on Nestri \ No newline at end of file diff --git a/apps/docs/content/4.nestri-internal/2.setup.md b/apps/docs/content/4.nestri-internal/2.setup.md new file mode 100644 index 00000000..943536f8 --- /dev/null +++ b/apps/docs/content/4.nestri-internal/2.setup.md @@ -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 \ No newline at end of file diff --git a/apps/docs/content/4.nestri-internal/_dir.yml b/apps/docs/content/4.nestri-internal/_dir.yml new file mode 100644 index 00000000..cb1fa02e --- /dev/null +++ b/apps/docs/content/4.nestri-internal/_dir.yml @@ -0,0 +1,2 @@ +title: 'Nestri Internals' +icon: heroicons-outline:bookmark-alt diff --git a/apps/www/package.json b/apps/www/package.json index 21dc2d7e..0e4a221f 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -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", diff --git a/infra/api.ts b/infra/api.ts index 2877b0c4..649fb2f8 100644 --- a/infra/api.ts +++ b/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 -} \ No newline at end of file + api: api.url, +}; \ No newline at end of file diff --git a/infra/bus.ts b/infra/bus.ts new file mode 100644 index 00000000..43a47f98 --- /dev/null +++ b/infra/bus.ts @@ -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: ["*"], + }, + ], + }); \ No newline at end of file diff --git a/infra/database.ts b/infra/database.ts new file mode 100644 index 00000000..44f0e0f6 --- /dev/null +++ b/infra/database.ts @@ -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, + }, +}); \ No newline at end of file diff --git a/infra/email.ts b/infra/email.ts new file mode 100644 index 00000000..e0dd84ed --- /dev/null +++ b/infra/email.ts @@ -0,0 +1,6 @@ +import { domain } from "./dns"; + +export const email = new sst.aws.Email("Mail",{ + sender: domain, + dns: sst.cloudflare.dns(), +}) \ No newline at end of file diff --git a/infra/secret.ts b/infra/secret.ts new file mode 100644 index 00000000..58466b89 --- /dev/null +++ b/infra/secret.ts @@ -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); \ No newline at end of file diff --git a/infra/www.ts b/infra/www.ts new file mode 100644 index 00000000..24ad1784 --- /dev/null +++ b/infra/www.ts @@ -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, + }, +}) \ No newline at end of file diff --git a/infra:old/api.ts b/infra:old/api.ts new file mode 100644 index 00000000..2877b0c4 --- /dev/null +++ b/infra:old/api.ts @@ -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 +} \ No newline at end of file diff --git a/infra/auth.ts b/infra:old/auth.ts similarity index 100% rename from infra/auth.ts rename to infra:old/auth.ts diff --git a/infra/cluster.ts b/infra:old/cluster.ts similarity index 100% rename from infra/cluster.ts rename to infra:old/cluster.ts diff --git a/infra:old/dns.ts b/infra:old/dns.ts new file mode 100644 index 00000000..e23d3a8e --- /dev/null +++ b/infra:old/dns.ts @@ -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", + }); \ No newline at end of file diff --git a/infra/party.ts b/infra:old/party.ts similarity index 100% rename from infra/party.ts rename to infra:old/party.ts diff --git a/infra/relay.ts b/infra:old/relay.ts similarity index 100% rename from infra/relay.ts rename to infra:old/relay.ts diff --git a/infra/secrets.ts b/infra:old/secrets.ts similarity index 100% rename from infra/secrets.ts rename to infra:old/secrets.ts diff --git a/infra/ssh.ts b/infra:old/ssh.ts similarity index 100% rename from infra/ssh.ts rename to infra:old/ssh.ts diff --git a/infra/stage.ts b/infra:old/stage.ts similarity index 100% rename from infra/stage.ts rename to infra:old/stage.ts diff --git a/infra/storage.ts b/infra:old/storage.ts similarity index 100% rename from infra/storage.ts rename to infra:old/storage.ts diff --git a/infra/vpc.ts b/infra:old/vpc.ts similarity index 100% rename from infra/vpc.ts rename to infra:old/vpc.ts diff --git a/old.sst.config.ts b/old.sst.config.ts new file mode 100644 index 00000000..71bc2c45 --- /dev/null +++ b/old.sst.config.ts @@ -0,0 +1,29 @@ +/// +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; + }, +}); diff --git a/package.json b/package.json index 289c53b0..407f82af 100644 --- a/package.json +++ b/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" } -} \ No newline at end of file +} diff --git a/packages/core/drizzle.config.ts b/packages/core/drizzle.config.ts new file mode 100644 index 00000000..98e3f64a --- /dev/null +++ b/packages/core/drizzle.config.ts @@ -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`, + }, +}); \ No newline at end of file diff --git a/packages/core/instant.perms.ts b/packages/core/instant.perms.ts deleted file mode 100644 index 91d24755..00000000 --- a/packages/core/instant.perms.ts +++ /dev/null @@ -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; diff --git a/packages/core/instant.schema.ts b/packages/core/instant.schema.ts deleted file mode 100644 index a7652352..00000000 --- a/packages/core/instant.schema.ts +++ /dev/null @@ -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; diff --git a/packages/core/migrations/0000_wise_black_widow.sql b/packages/core/migrations/0000_wise_black_widow.sql new file mode 100644 index 00000000..246056cc --- /dev/null +++ b/packages/core/migrations/0000_wise_black_widow.sql @@ -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"); \ No newline at end of file diff --git a/packages/core/migrations/0001_flaky_tomorrow_man.sql b/packages/core/migrations/0001_flaky_tomorrow_man.sql new file mode 100644 index 00000000..f20db0a1 --- /dev/null +++ b/packages/core/migrations/0001_flaky_tomorrow_man.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ALTER COLUMN "polar_customer_id" DROP NOT NULL; \ No newline at end of file diff --git a/packages/core/migrations/meta/0000_snapshot.json b/packages/core/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..61c10387 --- /dev/null +++ b/packages/core/migrations/meta/0000_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/0001_snapshot.json b/packages/core/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..dc153137 --- /dev/null +++ b/packages/core/migrations/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json new file mode 100644 index 00000000..b450e9db --- /dev/null +++ b/packages/core/migrations/meta/_journal.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index 60baa317..67488454 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" } } \ No newline at end of file diff --git a/packages/core/src/actor.ts b/packages/core/src/actor.ts index 85b311b4..f706f65a 100644 --- a/packages/core/src/actor.ts +++ b/packages/core/src/actor.ts @@ -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; + +export const UserActor = z.object({ + type: z.literal("user"), + properties: z.object({ + userID: z.string(), + email: z.string().nonempty(), + }), +}); +export type UserActor = z.infer; + +export const MemberActor = z.object({ + type: z.literal("member"), + properties: z.object({ + memberID: z.string(), + teamID: z.string(), + }), +}); +export type MemberActor = z.infer; + +export const SystemActor = z.object({ + type: z.literal("system"), + properties: z.object({ + teamID: z.string(), + }), +}); +export type SystemActor = z.infer; + +export const Actor = z.discriminatedUnion("type", [ + MemberActor, + UserActor, + PublicActor, + SystemActor, +]); +export type Actor = z.infer; + +const ActorContext = createContext("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(); - -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(type: T) { + const actor = useActor(); + if (actor.type !== type) { + throw new Error(`Expected actor type ${type}, got ${actor.type}`); } - - export function assertActor(type: T) { - const actor = useActor(); - if (actor.type !== type) - throw new VisibleError("auth", "actor.invalid", `Actor is not "${type}"`); - return actor as Extract; - } \ No newline at end of file + + return actor as Extract; +} + +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", + ); + }), + ); +} \ No newline at end of file diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index f160bab3..c1ca8e64 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -1,17 +1,17 @@ import { AsyncLocalStorage } from "node:async_hooks"; -export function createContext() { +export function createContext(name: string) { const storage = new AsyncLocalStorage(); return { use() { const result = storage.getStore(); if (!result) { - throw new Error("No context available"); + throw new Error("Context not provided: " + name); } return result; }, with(value: T, fn: () => R) { - return storage.run(value, fn); + return storage.run(value, fn); }, }; } \ No newline at end of file diff --git a/packages/core/src/drizzle/index.ts b/packages/core/src/drizzle/index.ts new file mode 100644 index 00000000..47d0e4d1 --- /dev/null +++ b/packages/core/src/drizzle/index.ts @@ -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, +}); \ No newline at end of file diff --git a/packages/core/src/drizzle/transaction.ts b/packages/core/src/drizzle/transaction.ts new file mode 100644 index 00000000..83576bd1 --- /dev/null +++ b/packages/core/src/drizzle/transaction.ts @@ -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, + ExtractTablesWithRelations> +>; + +type TxOrDb = Transaction | typeof db; + +const TransactionContext = createContext<{ + tx: Transaction; + effects: (() => void | Promise)[]; +}>("TransactionContext"); + +export async function useTransaction(callback: (trx: TxOrDb) => Promise) { + try { + const { tx } = TransactionContext.use(); + return callback(tx); + } catch { + return callback(db); + } +} + +export async function afterTx(effect: () => any | Promise) { + try { + const { effects } = TransactionContext.use(); + effects.push(effect); + } catch { + await effect(); + } +} + +export async function createTransaction( + callback: (tx: Transaction) => Promise, + isolationLevel?: PgTransactionConfig["isolationLevel"], +): Promise { + try { + const { tx } = TransactionContext.use(); + return callback(tx); + } catch { + const effects: (() => void | Promise)[] = []; + 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; + } +} \ No newline at end of file diff --git a/packages/core/src/drizzle/types.ts b/packages/core/src/drizzle/types.ts new file mode 100644 index 00000000..ebd4e668 --- /dev/null +++ b/packages/core/src/drizzle/types.ts @@ -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"), +}; \ No newline at end of file diff --git a/packages/core/src/email/index.ts b/packages/core/src/email/index.ts index 769f1b31..3698f0c6 100644 --- a/packages/core/src/email/index.ts +++ b/packages/core/src/email/index.ts @@ -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}>`, + }), + ); + } } \ No newline at end of file diff --git a/packages/core/src/error.ts b/packages/core/src/error.ts index 9aab0425..c713b307 100644 --- a/packages/core/src/error.ts +++ b/packages/core/src/error.ts @@ -1,6 +1,5 @@ export class VisibleError extends Error { constructor( - public kind: "input" | "auth", public code: string, public message: string, ) { diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts new file mode 100644 index 00000000..9b15dc47 --- /dev/null +++ b/packages/core/src/event.ts @@ -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(), + }; + }, +}); \ No newline at end of file diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 6792355b..bb59bb93 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -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' - } } \ No newline at end of file diff --git a/packages/core/src/member/index.ts b/packages/core/src/member/index.ts new file mode 100644 index 00000000..1df83562 --- /dev/null +++ b/packages/core/src/member/index.ts @@ -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; + + 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 { + return { + id: input.id, + email: input.email, + teamID: input.teamID, + timeSeen: input.timeSeen + }; + } + +} \ No newline at end of file diff --git a/packages/core/src/member/member.sql.ts b/packages/core/src/member/member.sql.ts new file mode 100644 index 00000000..4e1dfd96 --- /dev/null +++ b/packages/core/src/member/member.sql.ts @@ -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), + ], +); \ No newline at end of file diff --git a/packages/core/src/polar.ts b/packages/core/src/polar.ts new file mode 100644 index 00000000..660aff52 --- /dev/null +++ b/packages/core/src/polar.ts @@ -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; +} \ No newline at end of file diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts index f472be54..1db157bc 100644 --- a/packages/core/src/team/index.ts +++ b/packages/core/src/team/index.ts @@ -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; - 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 { + 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" - }) + }; + } } \ No newline at end of file diff --git a/packages/core/src/team/team.sql.ts b/packages/core/src/team/team.sql.ts new file mode 100644 index 00000000..f88f09c9 --- /dev/null +++ b/packages/core/src/team/team.sql.ts @@ -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], + }), + ]; +} \ No newline at end of file diff --git a/packages/core/src/user/index.ts b/packages/core/src/user/index.ts index 278fbbcc..439e1590 100644 --- a/packages/core/src/user/index.ts +++ b/packages/core/src/user/index.ts @@ -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; + + 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 { + 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)) + ); + } } \ No newline at end of file diff --git a/packages/core/src/user/user.sql.ts b/packages/core/src/user/user.sql.ts new file mode 100644 index 00000000..edf8ee3c --- /dev/null +++ b/packages/core/src/user/user.sql.ts @@ -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; + +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().default({}), + }, + (user) => [ + uniqueIndex("user_email").on(user.email), + ], +); \ No newline at end of file diff --git a/packages/core/src/utils/id.ts b/packages/core/src/utils/id.ts new file mode 100644 index 00000000..d79d8bc1 --- /dev/null +++ b/packages/core/src/utils/id.ts @@ -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("_"); +} \ No newline at end of file diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index e9dcbbde..16ec78f3 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1 +1,2 @@ -export * from "./fn" \ No newline at end of file +export * from "./fn" +export * from "./id" \ No newline at end of file diff --git a/packages/core/src:old/actor.ts b/packages/core/src:old/actor.ts new file mode 100644 index 00000000..6fd4296a --- /dev/null +++ b/packages/core/src:old/actor.ts @@ -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(); + +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(type: T) { + const actor = useActor(); + if (actor.type !== type) + throw new VisibleError("auth", "actor.invalid", `Actor is not "${type}"`); + return actor as Extract; + } \ No newline at end of file diff --git a/packages/core/src/aws/client.ts b/packages/core/src:old/aws/client.ts similarity index 100% rename from packages/core/src/aws/client.ts rename to packages/core/src:old/aws/client.ts diff --git a/packages/core/src:old/common.ts b/packages/core/src:old/common.ts new file mode 100644 index 00000000..a7ec75d8 --- /dev/null +++ b/packages/core/src:old/common.ts @@ -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.`; +} \ No newline at end of file diff --git a/packages/core/src/database.ts b/packages/core/src:old/database.ts similarity index 100% rename from packages/core/src/database.ts rename to packages/core/src:old/database.ts diff --git a/packages/core/src:old/email/index.ts b/packages/core/src:old/email/index.ts new file mode 100644 index 00000000..769f1b31 --- /dev/null +++ b/packages/core/src:old/email/index.ts @@ -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) + } + } +} \ No newline at end of file diff --git a/packages/core/src:old/error.ts b/packages/core/src:old/error.ts new file mode 100644 index 00000000..9aab0425 --- /dev/null +++ b/packages/core/src:old/error.ts @@ -0,0 +1,9 @@ +export class VisibleError extends Error { + constructor( + public kind: "input" | "auth", + public code: string, + public message: string, + ) { + super(message); + } + } \ No newline at end of file diff --git a/packages/core/src:old/examples.ts b/packages/core/src:old/examples.ts new file mode 100644 index 00000000..6792355b --- /dev/null +++ b/packages/core/src:old/examples.ts @@ -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' + } +} \ No newline at end of file diff --git a/packages/core/src/game/index.ts b/packages/core/src:old/game/index.ts similarity index 100% rename from packages/core/src/game/index.ts rename to packages/core/src:old/game/index.ts diff --git a/packages/core/src/instance/index.ts b/packages/core/src:old/instance/index.ts similarity index 100% rename from packages/core/src/instance/index.ts rename to packages/core/src:old/instance/index.ts diff --git a/packages/core/src/machine/index.ts b/packages/core/src:old/machine/index.ts similarity index 100% rename from packages/core/src/machine/index.ts rename to packages/core/src:old/machine/index.ts diff --git a/packages/core/src/profile/index.ts b/packages/core/src:old/profile/index.ts similarity index 100% rename from packages/core/src/profile/index.ts rename to packages/core/src:old/profile/index.ts diff --git a/packages/core/src/session/index.ts b/packages/core/src:old/session/index.ts similarity index 100% rename from packages/core/src/session/index.ts rename to packages/core/src:old/session/index.ts diff --git a/packages/core/src/subscription/index.ts b/packages/core/src:old/subscription/index.ts similarity index 100% rename from packages/core/src/subscription/index.ts rename to packages/core/src:old/subscription/index.ts diff --git a/packages/core/src/task/index.ts b/packages/core/src:old/task/index.ts similarity index 100% rename from packages/core/src/task/index.ts rename to packages/core/src:old/task/index.ts diff --git a/packages/core/src:old/team/index.ts b/packages/core/src:old/team/index.ts new file mode 100644 index 00000000..f472be54 --- /dev/null +++ b/packages/core/src:old/team/index.ts @@ -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; + + 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" + }) + +} \ No newline at end of file diff --git a/packages/core/src/types.ts b/packages/core/src:old/types.ts similarity index 100% rename from packages/core/src/types.ts rename to packages/core/src:old/types.ts diff --git a/packages/core/src:old/user/index.ts b/packages/core/src:old/user/index.ts new file mode 100644 index 00000000..278fbbcc --- /dev/null +++ b/packages/core/src:old/user/index.ts @@ -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 + }) +} \ No newline at end of file diff --git a/packages/core/src:old/utils/fn.ts b/packages/core/src:old/utils/fn.ts new file mode 100644 index 00000000..105656b3 --- /dev/null +++ b/packages/core/src:old/utils/fn.ts @@ -0,0 +1,27 @@ +import { ZodSchema, z } from "zod"; + +export function fn< + Arg1 extends ZodSchema, + Callback extends (arg1: z.output) => any, +>(arg1: Arg1, cb: Callback) { + const result = function (input: z.input): ReturnType { + 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, arg2: z.output) => any, +>(arg1: Arg1, arg2: Arg2, cb: Callback) { + const result = function (input: z.input, input2: z.input): ReturnType { + 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; +} \ No newline at end of file diff --git a/packages/core/src:old/utils/id.ts b/packages/core/src:old/utils/id.ts new file mode 100644 index 00000000..badcc0d9 --- /dev/null +++ b/packages/core/src:old/utils/id.ts @@ -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("_"); +} \ No newline at end of file diff --git a/packages/core/src:old/utils/index.ts b/packages/core/src:old/utils/index.ts new file mode 100644 index 00000000..16ec78f3 --- /dev/null +++ b/packages/core/src:old/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./fn" +export * from "./id" \ No newline at end of file diff --git a/packages/functions/package.json b/packages/functions/package.json index fae8f78f..29ed020f 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -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" diff --git a/packages/functions/src/adapter.ts b/packages/functions/src/adapter.ts deleted file mode 100644 index 14be1907..00000000 --- a/packages/functions/src/adapter.ts +++ /dev/null @@ -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 - } - -export type ApiAdapterError = - | { - type: "invalid_code" - } - | { - type: "invalid_claim" - key: string - value: string - } - -export function ApiAdapter< - Claims extends Record = Record, ->(config: { - length?: number - request: ( - req: Request, - state: ApiAdapterState, - body?: Claims, - error?: ApiAdapterError, - ) => Promise - sendCode: (claims: Claims, code: string) => Promise -}) { - 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(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(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[0] \ No newline at end of file diff --git a/packages/functions/src/api/account.ts b/packages/functions/src/api/account.ts new file mode 100644 index 00000000..32ce1de7 --- /dev/null +++ b/packages/functions/src/api/account.ts @@ -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); + }, + ) +} \ No newline at end of file diff --git a/packages/functions/src/api/auth.ts b/packages/functions/src/api/auth.ts new file mode 100644 index 00000000..708c2228 --- /dev/null +++ b/packages/functions/src/api/auth.ts @@ -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, + // ); + // }, + ); + } +}; \ No newline at end of file diff --git a/packages/functions/src/api/game.ts b/packages/functions/src/api/game.ts deleted file mode 100644 index 0bb02360..00000000 --- a/packages/functions/src/api/game.ts +++ /dev/null @@ -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); -// }, -// ); -// } \ No newline at end of file diff --git a/packages/functions/src/api/index.ts b/packages/functions/src/api/index.ts index b30a4a06..e8e77c8b 100644 --- a/packages/functions/src/api/index.ts +++ b/packages/functions/src/api/index.ts @@ -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 \ No newline at end of file +export const handler = process.env.SST_DEV ? handle(app) : streamHandle(app); \ No newline at end of file diff --git a/packages/functions/src/api/machine.ts b/packages/functions/src/api/machine.ts deleted file mode 100644 index 43d43288..00000000 --- a/packages/functions/src/api/machine.ts +++ /dev/null @@ -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); -// }, -// ); -// } \ No newline at end of file diff --git a/packages/functions/src/api/session.ts b/packages/functions/src/api/session.ts deleted file mode 100644 index 74a2b60c..00000000 --- a/packages/functions/src/api/session.ts +++ /dev/null @@ -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); - }, - ); -} \ No newline at end of file diff --git a/packages/functions/src/api/subscription.ts b/packages/functions/src/api/subscription.ts deleted file mode 100644 index 238ae331..00000000 --- a/packages/functions/src/api/subscription.ts +++ /dev/null @@ -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); - }, - ); -} \ No newline at end of file diff --git a/packages/functions/src/api/task.ts b/packages/functions/src/api/task.ts deleted file mode 100644 index 8c9f5c6f..00000000 --- a/packages/functions/src/api/task.ts +++ /dev/null @@ -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); - }, - ) -} \ No newline at end of file diff --git a/packages/functions/src/api/team.ts b/packages/functions/src/api/team.ts deleted file mode 100644 index 2d18fa2f..00000000 --- a/packages/functions/src/api/team.ts +++ /dev/null @@ -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); - }, - ) -} \ No newline at end of file diff --git a/packages/functions/src/api/user.ts b/packages/functions/src/api/user.ts deleted file mode 100644 index b7425e05..00000000 --- a/packages/functions/src/api/user.ts +++ /dev/null @@ -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); - }, - ) -} \ No newline at end of file diff --git a/packages/functions/src/auth.ts b/packages/functions/src/auth.ts index d12bfe07..41db11cf 100644 --- a/packages/functions/src/auth.ts +++ b/packages/functions/src/auth.ts @@ -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 - } +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) - } -} \ No newline at end of file +export const handler = handle(app) diff --git a/packages/functions/src/event/event.ts b/packages/functions/src/event/event.ts new file mode 100644 index 00000000..beb1d38c --- /dev/null +++ b/packages/functions/src/event/event.ts @@ -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; + } + } + }, +); \ No newline at end of file diff --git a/packages/functions/src/subjects.ts b/packages/functions/src/subjects.ts index 35999e4d..3ec2ddaf 100644 --- a/packages/functions/src/subjects.ts +++ b/packages/functions/src/subjects.ts @@ -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(), + // }) }) \ No newline at end of file diff --git a/packages/functions/src/ui/adapters/oauth2.tsx b/packages/functions/src/ui/adapters/oauth2.tsx index a1ccb711..5331d2d5 100644 --- a/packages/functions/src/ui/adapters/oauth2.tsx +++ b/packages/functions/src/ui/adapters/oauth2.tsx @@ -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", diff --git a/packages/functions/src/ui/adapters/password.ts b/packages/functions/src/ui/adapters/password.ts index a86ae1b8..623d181f 100644 --- a/packages/functions/src/ui/adapters/password.ts +++ b/packages/functions/src/ui/adapters/password.ts @@ -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 { @@ -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 diff --git a/packages/functions/src/utils.ts b/packages/functions/src/utils.ts index 15b7f0e2..dce82db7 100644 --- a/packages/functions/src/utils.ts +++ b/packages/functions/src/utils.ts @@ -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", diff --git a/packages/functions/sst-env.d.ts b/packages/functions/sst-env.d.ts index 993c6b43..e0f0b5f6 100644 --- a/packages/functions/sst-env.d.ts +++ b/packages/functions/sst-env.d.ts @@ -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 + } } } diff --git a/packages/scripts/src/psql.ts b/packages/scripts/src/psql.ts new file mode 100755 index 00000000..2c020823 --- /dev/null +++ b/packages/scripts/src/psql.ts @@ -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", + }, +); \ No newline at end of file diff --git a/packages/ui/.eslintrc.cjs b/packages/ui/.eslintrc.cjs index 92f00457..74abe5a9 100644 --- a/packages/ui/.eslintrc.cjs +++ b/packages/ui/.eslintrc.cjs @@ -5,7 +5,7 @@ module.exports = { es2021: true, node: true, }, - extends: [ + extends: [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:qwik/recommended", diff --git a/packages/www/.gitignore b/packages/www/.gitignore new file mode 100644 index 00000000..9b1ee42e --- /dev/null +++ b/packages/www/.gitignore @@ -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 diff --git a/packages/www/README.md b/packages/www/README.md new file mode 100644 index 00000000..c1267dca --- /dev/null +++ b/packages/www/README.md @@ -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. diff --git a/packages/www/index.html b/packages/www/index.html new file mode 100644 index 00000000..3ec7a691 --- /dev/null +++ b/packages/www/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + Nestri - Your games. Your rules. + + + +
+ + + diff --git a/packages/www/package.json b/packages/www/package.json new file mode 100644 index 00000000..2d046cd0 --- /dev/null +++ b/packages/www/package.json @@ -0,0 +1,32 @@ +{ + "name": "@nestri/www", + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "typecheck": "tsc --noEmit --incremental" + }, + "devDependencies": { + "@macaron-css/vite": "^1.5.1", + "@types/bun": "latest", + "vite": "5.4.10", + "vite-plugin-solid": "^2.11.2" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@fontsource-variable/geist-mono": "^5.0.1", + "@nestri/core": "*", + "@fontsource-variable/mona-sans": "^5.0.1", + "@fontsource/geist-sans": "^5.1.0", + "@macaron-css/core": "^1.5.2", + "@macaron-css/solid": "^1.5.3", + "@solid-primitives/storage": "^4.3.1", + "@solidjs/router": "^0.15.3", + "modern-normalize": "^3.0.1", + "solid-js": "^1.9.5" + } +} \ No newline at end of file diff --git a/packages/www/src/App.tsx b/packages/www/src/App.tsx new file mode 100644 index 00000000..efd3487d --- /dev/null +++ b/packages/www/src/App.tsx @@ -0,0 +1,147 @@ +import '@fontsource-variable/mona-sans'; +import '@fontsource-variable/geist-mono'; +import '@fontsource/geist-sans/400.css'; +import '@fontsource/geist-sans/500.css'; +import '@fontsource/geist-sans/600.css'; +import '@fontsource/geist-sans/700.css'; +import '@fontsource/geist-sans/800.css'; +import '@fontsource/geist-sans/900.css'; +import { TeamCreate } from './pages/new'; +import { styled } from "@macaron-css/solid"; +import { useStorage } from './providers/account'; +import { darkClass, lightClass, theme } from './ui/theme'; +import { AuthProvider, useAuth } from './providers/auth'; +import { Navigate, Route, Router } from "@solidjs/router"; +import { globalStyle, macaron$ } from "@macaron-css/core"; +import { Component, createSignal, Match, onCleanup, Switch } from 'solid-js'; + +const Root = styled("div", { + base: { + inset: 0, + lineHeight: 1, + fontSynthesis: "none", + color: theme.color.d1000.gray, + fontFamily: theme.font.family.body, + textRendering: "optimizeLegibility", + WebkitFontSmoothing: "antialised", + backgroundColor: theme.color.background.d100, + }, +}); + +globalStyle("html", { + fontSize: 16, + fontWeight: 400, + // Hardcode colors + "@media": { + "(prefers-color-scheme: light)": { + backgroundColor: "hsla(0,0%,98%)", + }, + "(prefers-color-scheme: dark)": { + backgroundColor: "hsla(0,0%,0%)", + }, + }, +}); + +globalStyle("h1, h2, h3, h4, h5, h6, p", { + margin: 0, +}); + +macaron$(() => + ["::placeholder", ":-ms-input-placeholder"].forEach((selector) => + globalStyle(selector, { + opacity: 1, + color: theme.color.d1000.gray, + }), + ), +); + +globalStyle("body", { + cursor: "default", +}); + +globalStyle("*", { + boxSizing: "border-box", +}); + +export const App: Component = () => { + const [theme, setTheme] = createSignal( + window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light", + ); + + const darkMode = window.matchMedia("(prefers-color-scheme: dark)"); + const setColorScheme = (e: MediaQueryListEvent) => { + setTheme(e.matches ? "dark" : "light"); + }; + darkMode.addEventListener("change", setColorScheme); + onCleanup(() => { + darkMode.removeEventListener("change", setColorScheme); + }); + + const storage = useStorage(); + + return ( + + + ( + + {props.children} + + // + // + // + // + // + // + // + // + // + // {props.children} + // + // + // + // + // + // + // + // + )} + > + {/* + + + + {WorkspaceRoute} */} + + { + const auth = useAuth(); + return ( + + 0}> + w.id === storage.value.team, + ) || auth.current.teams[0] + ).slug + }`} + /> + + + + + + ); + }} + /> + {/* } /> */} + + + + ) +} \ No newline at end of file diff --git a/packages/www/src/assets/fonts/Geist.ttf b/packages/www/src/assets/fonts/Geist.ttf new file mode 100644 index 00000000..40d1d131 Binary files /dev/null and b/packages/www/src/assets/fonts/Geist.ttf differ diff --git a/packages/www/src/assets/fonts/GeistMono.ttf b/packages/www/src/assets/fonts/GeistMono.ttf new file mode 100644 index 00000000..5ea4e68f Binary files /dev/null and b/packages/www/src/assets/fonts/GeistMono.ttf differ diff --git a/packages/www/src/assets/fonts/MonaSansVF-Regular.ttf b/packages/www/src/assets/fonts/MonaSansVF-Regular.ttf new file mode 100644 index 00000000..61db3f38 Binary files /dev/null and b/packages/www/src/assets/fonts/MonaSansVF-Regular.ttf differ diff --git a/packages/www/src/assets/fonts/MonaSansVF-Regular.woff b/packages/www/src/assets/fonts/MonaSansVF-Regular.woff new file mode 100644 index 00000000..cc55c67b Binary files /dev/null and b/packages/www/src/assets/fonts/MonaSansVF-Regular.woff differ diff --git a/packages/www/src/assets/fonts/MonaSansVF-Regular.woff2 b/packages/www/src/assets/fonts/MonaSansVF-Regular.woff2 new file mode 100644 index 00000000..9b73cd28 Binary files /dev/null and b/packages/www/src/assets/fonts/MonaSansVF-Regular.woff2 differ diff --git a/packages/www/src/assets/seo/android-chrome-192x192.png b/packages/www/src/assets/seo/android-chrome-192x192.png new file mode 100644 index 00000000..28422101 Binary files /dev/null and b/packages/www/src/assets/seo/android-chrome-192x192.png differ diff --git a/packages/www/src/assets/seo/android-chrome-512x512.png b/packages/www/src/assets/seo/android-chrome-512x512.png new file mode 100644 index 00000000..6b046664 Binary files /dev/null and b/packages/www/src/assets/seo/android-chrome-512x512.png differ diff --git a/packages/www/src/assets/seo/apple-touch-icon.png b/packages/www/src/assets/seo/apple-touch-icon.png new file mode 100644 index 00000000..f9de3316 Binary files /dev/null and b/packages/www/src/assets/seo/apple-touch-icon.png differ diff --git a/packages/www/src/assets/seo/banner.png b/packages/www/src/assets/seo/banner.png new file mode 100644 index 00000000..3d33ebbe Binary files /dev/null and b/packages/www/src/assets/seo/banner.png differ diff --git a/packages/www/src/assets/seo/browserconfig.xml b/packages/www/src/assets/seo/browserconfig.xml new file mode 100644 index 00000000..7c714d03 --- /dev/null +++ b/packages/www/src/assets/seo/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #ffede5 + + + diff --git a/packages/www/src/assets/seo/code.avif b/packages/www/src/assets/seo/code.avif new file mode 100644 index 00000000..a5cf1261 Binary files /dev/null and b/packages/www/src/assets/seo/code.avif differ diff --git a/packages/www/src/assets/seo/favicon-16x16.png b/packages/www/src/assets/seo/favicon-16x16.png new file mode 100644 index 00000000..9f22bfef Binary files /dev/null and b/packages/www/src/assets/seo/favicon-16x16.png differ diff --git a/packages/www/src/assets/seo/favicon-32x32.png b/packages/www/src/assets/seo/favicon-32x32.png new file mode 100644 index 00000000..79e1cce5 Binary files /dev/null and b/packages/www/src/assets/seo/favicon-32x32.png differ diff --git a/packages/www/src/assets/seo/favicon.ico b/packages/www/src/assets/seo/favicon.ico new file mode 100644 index 00000000..66491241 Binary files /dev/null and b/packages/www/src/assets/seo/favicon.ico differ diff --git a/packages/www/src/assets/seo/image.png b/packages/www/src/assets/seo/image.png new file mode 100644 index 00000000..db99ba60 Binary files /dev/null and b/packages/www/src/assets/seo/image.png differ diff --git a/packages/www/src/assets/seo/mstile-150x150.png b/packages/www/src/assets/seo/mstile-150x150.png new file mode 100644 index 00000000..13f0df9d Binary files /dev/null and b/packages/www/src/assets/seo/mstile-150x150.png differ diff --git a/packages/www/src/assets/seo/safari-pinned-tab.svg b/packages/www/src/assets/seo/safari-pinned-tab.svg new file mode 100644 index 00000000..0090cd78 --- /dev/null +++ b/packages/www/src/assets/seo/safari-pinned-tab.svg @@ -0,0 +1,19 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + diff --git a/packages/www/src/assets/seo/site.webmanifest b/packages/www/src/assets/seo/site.webmanifest new file mode 100644 index 00000000..4853690e --- /dev/null +++ b/packages/www/src/assets/seo/site.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Nestri", + "short_name": "Nestri", + "icons": [ + { + "src": "/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#fafafa", + "background_color": "#fafafa", + "display": "standalone"} diff --git a/packages/www/src/common/context.tsx b/packages/www/src/common/context.tsx new file mode 100644 index 00000000..b4484bf6 --- /dev/null +++ b/packages/www/src/common/context.tsx @@ -0,0 +1,26 @@ +import { ParentProps, Show, createContext, useContext } from "solid-js"; + +export function createInitializedContext< + Name extends string, + T extends { ready: boolean } +>(name: Name, cb: () => T) { + const ctx = createContext(); + + return { + use: () => { + const context = useContext(ctx); + if (!context) throw new Error(`No ${name} context`); + return context; + }, + provider: (props: ParentProps) => { + const value = cb(); + return ( + + + {props.children} + + + ); + }, + } +} \ No newline at end of file diff --git a/packages/www/src/index.tsx b/packages/www/src/index.tsx new file mode 100644 index 00000000..608b9165 --- /dev/null +++ b/packages/www/src/index.tsx @@ -0,0 +1,27 @@ +/* @refresh reload */ +import { render } from "solid-js/web"; +// import posthog from "posthog-js"; +// posthog.init("phc_M0b2lW4smpsGIufiTBZ22USKwCy0fyqljMOGufJc79p", { +// api_host: "https://telemetry.ion.sst.dev", +// }); + +import "modern-normalize/modern-normalize.css"; +import { App } from "./App"; +import { StorageProvider } from "./providers/account"; + +const root = document.getElementById("root"); + +if (import.meta.env.DEV && !(root instanceof HTMLElement)) { + throw new Error( + "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got mispelled?" + ); +} + +render( + () => ( + + + + ), + root! +); \ No newline at end of file diff --git a/packages/www/src/pages/default-state.tsx b/packages/www/src/pages/default-state.tsx new file mode 100644 index 00000000..c13befc0 --- /dev/null +++ b/packages/www/src/pages/default-state.tsx @@ -0,0 +1,7 @@ +export function DefaultState() { + return ( +
+ We are logging you in +
+ ) +} \ No newline at end of file diff --git a/packages/www/src/pages/new.tsx b/packages/www/src/pages/new.tsx new file mode 100644 index 00000000..fd34c95c --- /dev/null +++ b/packages/www/src/pages/new.tsx @@ -0,0 +1,15 @@ +import { Container, FullScreen } from "@nestri/www/ui/layout"; +import { Text } from "@nestri/www/ui/text"; + +export function TeamCreate() { + return ( + + + + Your first deploy is just a sign-up away. + + + + + ) +} \ No newline at end of file diff --git a/packages/www/src/providers/account.tsx b/packages/www/src/providers/account.tsx new file mode 100644 index 00000000..270f673f --- /dev/null +++ b/packages/www/src/providers/account.tsx @@ -0,0 +1,34 @@ +import { createStore } from "solid-js/store"; +import { makePersisted } from "@solid-primitives/storage"; +import { ParentProps, createContext, useContext } from "solid-js"; + +type Context = ReturnType; +const context = createContext(); + +function init() { + const [store, setStore] = makePersisted( + createStore({ + account: "", + team: "", + dummy: "", + }) + ); + + return { + value: store, + set: setStore, + }; +} + +export function StorageProvider(props: ParentProps) { + const ctx = init(); + return {props.children}; +} + +export function useStorage() { + const ctx = useContext(context); + if (!ctx) { + throw new Error("No storage context"); + } + return ctx; +} \ No newline at end of file diff --git a/packages/www/src/providers/auth.tsx b/packages/www/src/providers/auth.tsx new file mode 100644 index 00000000..bfe03a52 --- /dev/null +++ b/packages/www/src/providers/auth.tsx @@ -0,0 +1,222 @@ +import { type Team } from "@nestri/core/team/index"; +import { makePersisted } from "@solid-primitives/storage"; +import { useLocation, useNavigate } from "@solidjs/router"; +import { createClient } from "@openauthjs/openauth/client"; +import { createInitializedContext } from "../common/context"; +import { createEffect, createMemo, onMount } from "solid-js"; +import { createStore, produce, reconcile } from "solid-js/store"; + +interface AccountInfo { + id: string; + email: string; + name: string; + access: string; + refresh: string; + avatarUrl: string; + teams: Team.Info[]; + discriminator: number; + polarCustomerID: string | null; +} + +interface Storage { + accounts: Record; + current?: string; +} + +export const client = createClient({ + issuer: import.meta.env.VITE_AUTH_URL, + clientID: "web", +}); + +export const { use: useAuth, provider: AuthProvider } = + createInitializedContext("AuthContext", () => { + const [store, setStore] = makePersisted( + createStore({ + accounts: {}, + }), + { + name: "radiant.auth", + }, + ); + const location = useLocation(); + const params = createMemo( + () => new URLSearchParams(location.hash.substring(1)), + ); + const accessToken = createMemo(() => params().get("access_token")); + const refreshToken = createMemo(() => params().get("refresh_token")); + + + createEffect(async () => { + // if (!result.current && Object.keys(store.accounts).length) { + // result.switch(Object.keys(store.accounts)[0]) + // navigate("/") + // } + }) + + createEffect(async () => { + if (accessToken()) return; + if (Object.keys(store.accounts).length) return; + const redirect = await client.authorize(window.location.origin, "token"); + window.location.href = redirect.url + }); + + createEffect(async () => { + const current = store.current; + const accounts = store.accounts; + if (!current) return; + const match = accounts[current]; + if (match) return; + const keys = Object.keys(accounts); + if (keys.length) { + setStore("current", keys[0]); + navigate("/"); + return + } + const redirect = await client.authorize(window.location.origin, "token"); + window.location.href = redirect.url + }); + + async function refresh() { + for (const account of [...Object.values(store.accounts)]) { + if (!account.refresh) continue; + const result = await client.refresh(account.refresh, { + access: account.access, + }) + if (result.err) { + if ("id" in account) + setStore(produce((state) => { + delete state.accounts[account.id]; + })) + continue + }; + const tokens = result.tokens || { + access: account.access, + refresh: account.refresh, + } + fetch(import.meta.env.VITE_API_URL + "/account", { + headers: { + authorization: `Bearer ${tokens.access}`, + }, + }).then(async (response) => { + await new Promise((resolve) => setTimeout(resolve, 5000)); + + if (response.ok) { + const result = await response.json(); + const info = await result.data; + + setStore( + "accounts", + info.id, + reconcile({ + ...info, + ...tokens, + }), + ); + } + + if (!response.ok) + setStore( + produce((state) => { + delete state.accounts[account.id]; + }), + ); + }) + } + } + + onMount(async () => { + if (refreshToken() && accessToken()) { + const result = await fetch(import.meta.env.VITE_API_URL + "/account", { + headers: { + authorization: `Bearer ${accessToken()}`, + }, + }).catch(() => { }) + if (result?.ok) { + const response = await result.json(); + const info = await response.data; + setStore( + "accounts", + info.id, + reconcile({ + ...info, + access: accessToken(), + refresh: refreshToken(), + }), + ); + setStore("current", info.id); + } + window.location.hash = ""; + } + + await refresh(); + }) + + + const navigate = useNavigate(); + + // const bar = useCommandBar() + + // bar.register("auth", async () => { + // return [ + // { + // category: "Account", + // title: "Logout", + // icon: IconLogout, + // run: async (bar) => { + // result.logout(); + // setStore("current", undefined); + // navigate("/"); + // bar.hide() + // }, + // }, + // { + // category: "Add Account", + // title: "Add Account", + // icon: IconUserAdd, + // run: async () => { + // const redir = await client.authorize(window.location.origin, "token"); + // window.location.href = redir.url + // bar.hide() + // }, + // }, + // ...result.all() + // .filter((item) => item.id !== result.current.id) + // .map((item) => ({ + // category: "Account", + // title: "Switch to " + item.email, + // icon: IconUser, + // run: async () => { + // result.switch(item.id); + // navigate("/"); + // bar.hide() + // }, + // })), + // ] + // }) + + const result = { + get current() { + return store.accounts[store.current!]!; + }, + switch(accountID: string) { + setStore("current", accountID); + }, + all() { + return Object.values(store.accounts); + }, + refresh, + logout() { + setStore( + produce((state) => { + if (!state.current) return; + delete state.accounts[state.current]; + state.current = Object.keys(state.accounts)[0]; + }), + ); + }, + get ready() { + return Boolean(!accessToken() && store.current); + }, + }; + return result; + }); \ No newline at end of file diff --git a/packages/www/src/sst-env.d.ts b/packages/www/src/sst-env.d.ts new file mode 100644 index 00000000..46374ee0 --- /dev/null +++ b/packages/www/src/sst-env.d.ts @@ -0,0 +1,12 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/// +interface ImportMetaEnv { + readonly VITE_API_URL: string + readonly VITE_AUTH_URL: string + readonly VITE_STAGE: string +} +interface ImportMeta { + readonly env: ImportMetaEnv +} \ No newline at end of file diff --git a/packages/www/src/ui/layout.tsx b/packages/www/src/ui/layout.tsx new file mode 100644 index 00000000..d22dc083 --- /dev/null +++ b/packages/www/src/ui/layout.tsx @@ -0,0 +1,48 @@ +import { theme } from "./theme"; +import { styled } from "@macaron-css/solid"; + +export const FullScreen = styled("div", { + base: { + inset: 0, + zIndex: 0, + display: "flex", + position: "fixed", + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.color.background.d200, + }, + variants: { + inset: { + none: {}, + header: { + top: theme.headerHeight.root, + }, + }, + }, +}) + +export const Container = styled("div", { + base: { + backgroundColor: theme.color.background.d100, + borderColor: theme.color.gray.d400, + padding: "64px 80px 48px", + justifyContent: "center", + borderStyle: "solid", + position: "relative", + borderRadius: 12, + alignItems: "center", + maxWidth: 550, + borderWidth: 1, + display: "flex", + }, + variants: { + flow: { + column: { + flexDirection: "column" + }, + row: { + flexDirection: "row" + } + } + } +}) \ No newline at end of file diff --git a/packages/www/src/ui/text.tsx b/packages/www/src/ui/text.tsx new file mode 100644 index 00000000..93c332c6 --- /dev/null +++ b/packages/www/src/ui/text.tsx @@ -0,0 +1,167 @@ +import { theme } from "./theme"; +import { styled } from "@macaron-css/solid"; +import { utility } from "./utility"; +import { CSSProperties } from "@macaron-css/core"; + +export const Text = styled("span", { + base: { + textWrap: "balance" + }, + variants: { + leading: { + base: { + lineHeight: 1, + }, + normal: { + lineHeight: "normal", + }, + loose: { + lineHeight: theme.font.lineHeight, + }, + }, + align: { + left: { + textAlign: "left" + }, + center: { + textAlign: "center" + } + }, + spacing: { + none: { + letterSpacing: 0 + }, + xs: { + letterSpacing: -0.96 + }, + sm: { + letterSpacing: -0.96 + }, + md: { + letterSpacing: -1.28 + }, + lg: { + letterSpacing: -1.28 + } + }, + code: { + true: { + fontFamily: theme.font.family.code, + }, + }, + capitalize: { + true: { + textTransform: "capitalize", + }, + }, + uppercase: { + true: { + letterSpacing: 0.5, + textTransform: "uppercase", + }, + }, + weight: { + regular: { + fontWeight: theme.font.weight.regular, + }, + medium: { + fontWeight: theme.font.weight.medium, + }, + semibold: { + fontWeight: theme.font.weight.semibold, + }, + }, + center: { + true: { + textAlign: "center", + }, + }, + line: { + true: { + ...utility.text.line, + }, + }, + disableSelect: { + true: { + userSelect: "none", + WebkitUserSelect: "none", + }, + }, + pre: { + true: { + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + }, + }, + underline: { + true: { + textUnderlineOffset: 2, + textDecoration: "underline", + }, + }, + label: { + true: { + fontWeight: 500, + letterSpacing: 0.5, + textTransform: "uppercase", + fontFamily: theme.font.family.code, + }, + }, + break: { + true: { + wordBreak: "break-all", + }, + false: {}, + }, + size: (() => { + const result = {} as Record<`${keyof typeof theme.font.size}`, any>; + for (const [key, value] of Object.entries(theme.font.size)) { + result[key as keyof typeof theme.font.size] = { + fontSize: value, + }; + } + return result; + })(), + color: (() => { + const record = {} as Record; + for (const [key, _value] of Object.entries(theme.color.text)) { + record[key as keyof typeof record] = {}; + } + return record; + })(), + on: (() => { + const record = {} as Record< + keyof typeof theme.color.text.primary, + CSSProperties + >; + for (const [key, _value] of Object.entries(theme.color.text.primary)) { + record[key as keyof typeof record] = {}; + } + return record; + })(), + }, + compoundVariants: (() => { + const result: any[] = []; + for (const [color, ons] of Object.entries(theme.color.text)) { + for (const [on, value] of Object.entries(ons)) { + result.push({ + variants: { + color, + on, + }, + style: { + color: value, + }, + }); + } + } + return result; + })(), + defaultVariants: { + on: "base", + size: "base", + color: "primary", + spacing: "none", + weight: "regular", + }, +}); \ No newline at end of file diff --git a/packages/www/src/ui/theme.ts b/packages/www/src/ui/theme.ts new file mode 100644 index 00000000..31c223b1 --- /dev/null +++ b/packages/www/src/ui/theme.ts @@ -0,0 +1,426 @@ +import { createTheme } from "@macaron-css/core"; + +const constants = { + colorFadeDuration: "0.15s", + borderRadius: "4px", + textBoldWeight: "600", + iconOpacity: "0.85", + modalWidth: { + sm: "480px", + md: "640px", + lg: "800px", + }, + headerHeight: { + root: "68px", + stage: "52px", + }, +}; + +const space = { + px: "1px", + 0: "0px", + 0.5: "0.125rem", + 1: "0.25rem", + 1.5: "0.375rem", + 2: "0.5rem", + 2.5: "0.625rem", + 3: "0.75rem", + 3.5: "0.875rem", + 4: "1rem", + 5: "1.25rem", + 6: "1.5rem", + 7: "1.75rem", + 8: "2rem", + 9: "2.25rem", + 10: "2.5rem", + 11: "2.75rem", + 12: "3rem", + 14: "3.5rem", + 16: "4rem", + 20: "5rem", + 24: "6rem", + 28: "7rem", + 32: "8rem", + 36: "9rem", + 40: "10rem", + 44: "11rem", + 48: "12rem", + 52: "13rem", + 56: "14rem", + 60: "15rem", + 64: "16rem", + 72: "18rem", + 80: "20rem", + 96: "24rem", +}; + +const font = { + lineHeight: "1.6", + family: { + heading: '"Mona Sans Variable", sans-serif', + body: "'Geist Sans', sans-serif", + code: '"Geist Mono Variable", monospace', + }, + weight: { + regular: "400", + medium: "500", + semibold: "600", + bold: "700", + extrabold: "800" + }, + size: { + mono_xs: "0.6875rem", + xs: "0.75rem", + mono_sm: "0.8125rem", + sm: "0.875rem", + mono_base: "0.9375rem", + base: "1rem", + mono_lg: "1.0625rem", + lg: "1.125rem", + mono_xl: "1.1875rem", + xl: "1.25rem", + mono_2xl: "1.375rem", + "2xl": "1.5rem", + "3xl": "1.875rem", + "4xl": "2.25rem", + "5xl": "3rem", + "6xl": "3.75rem", + "7xl": "4.5rem", + "8xl": "6rem", + "9xl": "8rem", + }, +}; + +const light = (() => { + const gray = { + d100: 'hsla(0,0%,95%)', + d200: 'hsla(0,0%,92%)', + d300: 'hsla(0,0%,90%)', + d400: 'hsla(0,0%,92%)', + d500: 'hsla(0,0%,79%)', + d600: 'hsla(0,0%,66%)', + d700: 'hsla(0,0%,56%)', + d800: 'hsla(0,0%,49%)', + d900: 'hsla(0,0%,40%)', + }; + + const blue = { + d100: 'hsla(212,100%,97%)', + d200: 'hsla(210,100%,96%)', + d300: 'hsla(210,100%,94%)', + d400: 'hsla(209,100%,90%)', + d500: 'hsla(209,100%,80%)', + d600: 'hsla(208,100%,66%)', + d700: 'hsla(212,100%,48%)', + d800: 'hsla(212,100%,41%)', + d900: 'hsla(211,100%,42%)', + }; + + const red = { + d100: "hsla(0,100%,97%)", + d200: "hsla(0,100%,96%)", + d300: "hsla(0,100%,95%)", + d400: "hsla(0,90%,92%)", + d500: "hsla(0,82%,85%)", + d600: "hsla(359,90%,71%)", + d700: "hsla(358,75%,59%)", + d800: "hsla(358,70%,52%)", + d900: "hsla(358,66%,48%)", + }; + const amber = { + d100: "hsla(39,100%,95%)", + d200: "hsla(44,100%,92%)", + d300: "hsla( 43,96%,90%)", + d400: "hsla(42,100%,78%)", + d500: "hsla(38,100%,71%)", + d600: "hsla( 36,90%,62%)", + d700: "hsla(39,100%,57%)", + d800: "hsla(35,100%,52%)", + d900: "hsla(30,100%,32%)", + }; + const green = { + d100: "hsla(120,60%,96%)", + d200: "hsla(120,60%,95%)", + d300: "hsla(120,60%,91%)", + d400: "hsla(122,60%,86%)", + d500: "hsla(124,60%,75%)", + d600: "hsla(125,60%,64%)", + d700: "hsla(131,41%,46%)", + d800: "hsla(132,43%,39%)", + d900: "hsla(133,50%,32%)", + }; + const teal = { + d100: "hsla(169,70%,96%)", + d200: "hsla(167,70%,94%)", + d300: "hsla(168,70%,90%)", + d400: "hsla(170,70%,85%)", + d500: "hsla(170,70%,72%)", + d600: "hsla(170,70%,57%)", + d700: "hsla(173,80%,36%)", + d800: "hsla(173,83%,30%)", + d900: "hsla(174,91%,25%)", + }; + + const purple = { + d100: "hsla(276,100%,97%)", + d200: "hsla(277,87%,97%)", + d300: "hsla(274,78%,95%)", + d400: "hsla(276,71%,92%)", + d500: "hsla(274,70%,82%)", + d600: "hsla(273,72%,73%)", + d700: "hsla(272,51%,54%)", + d800: "hsla(272,47%,45%)", + d900: "hsla(274,71%,43%)", + }; + + const pink = { + d100: "hsla(330,100%,96%)", + d200: "hsla(340,90%,96%)", + d300: "hsla(340,82%,94%)", + d400: "hsla(341,76%,91%)", + d500: "hsla(340,75%,84%)", + d600: "hsla(341,75%,73%)", + d700: "hsla(336,80%,58%)", + d800: "hsla(336,74%,51%)", + d900: "hsla(336,65%,45%)", + }; + + const grayAlpha = { + d100: "rgba(0,0,0,0.05)", + d200: "hsla(0,0%,0%,0.08)", + d300: "hsla(0,0%,0%,0.1)", + d400: "hsla(0,0%,0%,0.08)", + d500: "hsla(0,0%,0%,0.21)", + d600: "hsla(0,0%,0%,0.34)", + d700: "hsla(0,0%,0%,0.44)", + d800: "hsla(0,0%,0%,0.51)", + d900: "hsla(0,0%,0%,0.61)", + }; + + const d1000 = { + gray: 'hsla(0,0%,9%)', + blue: 'hsla(211,100%,15%)', + red: "hsla(355,49%,15%)", + amber: "hsla(20,79%,17%)", + green: "hsla(128,29%,15%)", + teal: "hsla(171,80%,13%)", + purple: "hsla(276,100%,15)", + pink: "hsla(333,74%,15%)", + grayAlpha: " hsla(0,0%,0%,0.91)" + } + + const background = { + d100: 'hsla(0,0%,100%)', + d200: 'hsla(0,0%,98%)' + }; + + const contrastFg = '#ffffff'; + const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(0,0,0,0.16)`; + const focusColor = blue.d700 + + const text = { + primary: { + base: d1000.gray, + surface: gray.d900, + }, + info: { + base: d1000.amber, + surface: amber.d900, + }, + danger: { + base: d1000.red, + surface: red.d900, + }, + }; + + return { + gray, + blue, + red, + amber, + green, + teal, + purple, + pink, + grayAlpha, + background, + contrastFg, + focusBorder, + focusColor, + d1000, + text + }; +})() + +const dark = (() => { + const gray = { + d100: "hsla(0,0%,10%)", + d200: "hsla(0,0%,12%)", + d300: "hsla(0,0%,16%)", + d400: "hsla(0,0%,18%)", + d500: "hsla(0,0%,27%)", + d600: "hsla(0,0%,53%)", + d700: "hsla(0,0%,56%)", + d800: "hsla(0,0%,49%)", + d900: "hsla(0,0%,63%)", + }; + + const blue = { + d100: "hsla(216,50%,12%)", + d200: "hsla(214,59%,15%)", + d300: "hsla(213,71%,20%)", + d400: "hsla(212,78%,23%)", + d500: "hsla(211,86%,27%)", + d600: "hsla(206,100%,50%)", + d700: "hsla(212,100%,48%)", + d800: "hsla(212,100%,41%)", + d900: "hsla(210,100%,66%)", + }; + + const red = { + d200: "hsla(357,46%,16%)", + d100: "hsla(357,37%,12%)", + d300: "hsla(356,54%,22%)", + d400: "hsla(357,55%,26%)", + d500: "hsla(357,60%,32%)", + d600: "hsla(358,75%,59%)", + d700: "hsla(358,75%,59%)", + d800: "hsla(358,69%,52%)", + d900: "hsla(358,100%,69%)", + }; + const amber = { + d100: "hsla(35,100%,8%)", + d200: "hsla(32,100%,10%)", + d300: "hsla(33,100%,15%)", + d400: "hsla(35,100%,17%)", + d500: "hsla(35,91%,22%)", + d600: "hsla(39,85%,49%)", + d700: "hsla(39,100%,57%)", + d800: "hsla(35,100%,52%)", + d900: "hsla(39,90%,50%)", + }; + const green = { + d100: "hsla(136,50%,9%)", + d200: "hsla(137,50%,12%)", + d300: "hsla(136,50%,14%)", + d400: "hsla(135,70%,16%)", + d500: "hsla(135,70%,23%)", + d600: "hsla(135,70%,34%)", + d700: "hsla(131,41%,46%)", + d800: "hsla(132,43%,39%)", + d900: "hsla(131,43%,57%)", + }; + const teal = { + d100: "hsla(169,78%,7%)", + d200: "hsla(170,74%,9%)", + d300: "hsla(171,75%,13%)", + d400: "hsla(171,85%,13%)", + d500: "hsla(172,85%,20%)", + d600: "hsla(172,85%,32%)", + d700: "hsla(173,80%,36%)", + d800: "hsla(173,83%,30%)", + d900: "hsla(174,90%,41%)", + }; + const purple = { + d100: "hsla(283,30%,12%)", + d200: "hsla(281,38%,16%)", + d300: "hsla(279,44%,23%)", + d400: "hsla(277,46%,28%)", + d500: "hsla(274,49%,35%)", + d600: "hsla(272,51%,54%)", + d700: "hsla(272,51%,54%)", + d800: "hsla(272,47%,45%)", + d900: "hsla(275,80%,71%)", + }; + const pink = { + d100: "hsla(335,32%,12%)", + d200: "hsla(335,43%,16%)", + d300: "hsla(335,47%,21%)", + d400: "hsla(335,51%,22%)", + d500: "hsla(335,57%,27%)", + d600: "hsla(336,75%,40%)", + d700: "hsla(336,80%,58%)", + d800: "hsla(336,74%,51%)", + d900: "hsla(341,90%,67%)", + }; + + const grayAlpha = { + d100: "rgba(255,255,255,0.06)", + d200: "hsla(0,0%,100%,0.09)", + d300: "hsla(0,0%,100%,0.13)", + d400: "hsla(0,0%,100%,0.14)", + d500: "hsla(0,0%,100%,0.24)", + d600: "hsla(0,0%,100%,0.51)", + d700: "hsla(0,0%,100%,0.54)", + d800: "hsla(0,0%,100%,0.47)", + d900: "hsla(0,0%,100%,0.61)", + }; + + const d1000 = { + gray: 'hsla(0,0%,93%)', + blue: 'hsla( 206,100%,96%)', + red: "hsla( 353,90%,96%)", + amber: "hsla( 40,94%,93%))", + green: "hsla(136,73%,94%)", + teal: "hsla(166,71%,93%)", + purple: "hsla(281,73%,96%)", + pink: "hsla( 333,90%,96%)", + grayAlpha: "hsla(0,0%,100%,0.92)" + } + + const background = { + d100: 'hsla(0,0%,4%)', + d200: 'hsla(0,0%,0%)' + }; + const contrastFg = '#ffffff'; + const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(255,255,255,0.24)`; + const focusColor = blue.d900 + + const text = { + primary: { + base: d1000.gray, + surface: gray.d900, + }, + info: { + base: d1000.amber, + surface: amber.d900, + }, + danger: { + base: d1000.red, + surface: red.d900, + }, + }; + + return { + gray, + blue, + red, + amber, + green, + teal, + purple, + pink, + grayAlpha, + background, + contrastFg, + focusBorder, + focusColor, + d1000, + text + }; +})() + +export const [lightClass, theme] = createTheme({ + ...constants, + space, + font, + color: light, +}); + +export const darkClass = createTheme(theme, { + ...theme, + ...constants, + space, + font, + color: dark, +}); \ No newline at end of file diff --git a/packages/www/src/ui/utility.tsx b/packages/www/src/ui/utility.tsx new file mode 100644 index 00000000..adffb17c --- /dev/null +++ b/packages/www/src/ui/utility.tsx @@ -0,0 +1,42 @@ +import { theme } from "./theme"; + +export const utility = { + textLine() { + return { + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + } as any; + }, + stack(space: keyof (typeof theme)["space"]) { + return { + display: "flex", + flexDirection: "column", + gap: theme.space[space], + } as any; + }, + row(space: keyof (typeof theme)["space"]) { + return { + display: "flex", + gap: theme.space[space], + } as any; + }, + + text: { + line: { + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + } as any, + label: { + fontWeight: 500, + letterSpacing: 0.5, + textTransform: "uppercase", + fontFamily: theme.font.family.code, + } as any, + pre: { + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + } as any, + }, +}; \ No newline at end of file diff --git a/packages/www/sst-env.d.ts b/packages/www/sst-env.d.ts new file mode 100644 index 00000000..b6a7e906 --- /dev/null +++ b/packages/www/sst-env.d.ts @@ -0,0 +1,9 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/www/tsconfig.json b/packages/www/tsconfig.json new file mode 100644 index 00000000..1e3333d0 --- /dev/null +++ b/packages/www/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": [ + "vite/client" + ], + "noEmit": true, + "isolatedModules": true, + "baseUrl": ".", + "paths": { + "@nestri/www/*": [ + "./src/*" + ] + } + } +} \ No newline at end of file diff --git a/packages/www/vite.config.ts b/packages/www/vite.config.ts new file mode 100644 index 00000000..559e6998 --- /dev/null +++ b/packages/www/vite.config.ts @@ -0,0 +1,21 @@ +import path from "path"; +import { defineConfig } from "vite"; +import solidPlugin from "vite-plugin-solid"; +import { macaronVitePlugin } from "@macaron-css/vite"; + +export default defineConfig({ + //@ts-expect-error + plugins: [macaronVitePlugin(), solidPlugin()], + server: { + port: 3000, + host: "0.0.0.0", + }, + build: { + target: "esnext", + }, + resolve: { + alias: { + "@nestri/www": path.resolve(__dirname, "./src"), + }, + }, +}); \ No newline at end of file diff --git a/sst-env.d.ts b/sst-env.d.ts index 993c6b43..e0f0b5f6 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -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 + } } } diff --git a/sst.config.ts b/sst.config.ts index 71bc2c45..8fe8c41b 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -5,16 +5,17 @@ export default $config({ return { name: "nestri", removal: input?.stage === "production" ? "retain" : "remove", - home: "cloudflare", + protect: ["production"].includes(input?.stage), + home: "aws", 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", + aws: { + region: "us-east-1", + profile: + input.stage === "production" ? "nestri-production" : "nestri-dev", + }, + cloudflare: "5.49.0", + random: "4.17.0", + neon: "0.6.3", }, }; }, diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1 @@ +{}