diff --git a/infra/api.ts b/infra/api.ts index aa984c17..9800d11d 100644 --- a/infra/api.ts +++ b/infra/api.ts @@ -1,14 +1,14 @@ import { bus } from "./bus"; +import { vpc } from "./vpc"; import { auth } from "./auth"; import { domain } from "./dns"; import { secret } from "./secret"; -import { cluster } from "./cluster"; import { postgres } from "./postgres"; -export const apiService = new sst.aws.Service("Api", { - cluster, - cpu: $app.stage === "production" ? "2 vCPU" : undefined, - memory: $app.stage === "production" ? "4 GB" : undefined, +const apiFn = new sst.aws.Function("ApiFn", { + vpc, + handler: "packages/functions/src/api/index.handler", + streaming: !$dev, link: [ bus, auth, @@ -22,73 +22,90 @@ export const apiService = new sst.aws.Service("Api", { secret.NestriProMonthly, secret.NestriProYearly, ], - command: ["bun", "run", "./src/api/index.ts"], - image: { - dockerfile: "packages/functions/Containerfile", - }, - loadBalancer: { + url: true, +}); + +const provider = new aws.Provider("UsEast1", { region: "us-east-1" }); + +const webAcl = new aws.wafv2.WebAcl( + "ApiWaf", + { + scope: "CLOUDFRONT", + defaultAction: { + allow: {}, + }, + visibilityConfig: { + cloudwatchMetricsEnabled: true, + metricName: "api-rate-limit-metric", + sampledRequestsEnabled: true, + }, rules: [ { - listen: "80/http", - forward: "3001/http", + name: "rate-limit-rule", + priority: 1, + action: { + block: { + customResponse: { + responseCode: 429, + customResponseBodyKey: "rate-limit-response", + }, + }, + }, + statement: { + rateBasedStatement: { + limit: 2 * 60, // 2 rps per authorization header + evaluationWindowSec: 60, + aggregateKeyType: "CUSTOM_KEYS", + customKeys: [ + { + header: { + name: "Authorization", + textTransformations: [{ priority: 0, type: "NONE" }], + }, + }, + ], + }, + }, + visibilityConfig: { + cloudwatchMetricsEnabled: true, + metricName: "rate-limit-rule-metric", + sampledRequestsEnabled: true, + }, + }, + ], + customResponseBodies: [ + { + key: "rate-limit-response", + content: JSON.stringify({ + type: "rate_limit", + code: "too_many_requests", + message: "Rate limit exceeded. Please try again later.", + }), + contentType: "APPLICATION_JSON", }, ], }, - dev: { - url: "http://localhost:3001", - command: "bun dev:api", - directory: "packages/functions", - }, - scaling: - $app.stage === "production" - ? { - min: 2, - max: 10, - } - : undefined, - // For persisting actor state - transform: { - taskDefinition: (args) => { - const volumes = $output(args.volumes).apply(v => { - const next = [...v, { - name: "shared-tmp", - dockerVolumeConfiguration: { - scope: "shared", - driver: "local" - } - }]; + { provider }, +); - return next; - }) - - // "containerDefinitions" is a JSON string, parse first - let containers = $jsonParse(args.containerDefinitions); - - containers = containers.apply((containerDefinitions) => { - containerDefinitions[0].mountPoints = [ - ...(containerDefinitions[0].mountPoints ?? []), - { - sourceVolume: "shared-tmp", - containerPath: "/tmp" - }, - ] - return containerDefinitions; - }); - - args.volumes = volumes - args.containerDefinitions = $jsonStringify(containers); - } - } -}); - - -export const api = !$dev ? new sst.aws.Router("ApiRoute", { +export const api = new sst.aws.Router("Api", { routes: { - // I think api.url should work all the same - "/*": apiService.nodes.loadBalancer.dnsName, + "/*": apiFn.url, }, domain: { name: "api." + domain, dns: sst.cloudflare.dns(), }, -}) : apiService \ No newline at end of file + transform: { + cdn(args) { + if (!args.transform) { + args.transform = { + distribution: {}, + }; + } + args.transform!.distribution = { + webAclId: webAcl.arn, + }; + }, + }, +}); \ No newline at end of file diff --git a/infra/auth.ts b/infra/auth.ts index 37918a84..e10fb4e9 100644 --- a/infra/auth.ts +++ b/infra/auth.ts @@ -1,98 +1,32 @@ import { bus } from "./bus"; +import { vpc } from "./vpc"; import { domain } from "./dns"; import { secret } from "./secret"; -import { cluster } from "./cluster"; import { postgres } from "./postgres"; -export const authService = new sst.aws.Service("Auth", { - cluster, - cpu: $app.stage === "production" ? "1 vCPU" : undefined, - memory: $app.stage === "production" ? "2 GB" : undefined, - command: ["bun", "run", "./src/auth/index.ts"], - link: [ - bus, - postgres, - secret.PolarSecret, - secret.GithubClientID, - secret.DiscordClientID, - secret.GithubClientSecret, - secret.DiscordClientSecret, - ], - image: { - dockerfile: "packages/functions/Containerfile", - }, - environment: { - NO_COLOR: "1", - STORAGE: "/tmp/persist.json" - }, - loadBalancer: { - rules: [ +export const auth = new sst.aws.Auth("Auth", { + authorizer: { + vpc, + link: [ + bus, + postgres, + secret.PolarSecret, + secret.GithubClientID, + secret.DiscordClientID, + secret.GithubClientSecret, + secret.DiscordClientSecret, + ], + permissions: [ { - listen: "80/http", - forward: "3002/http", + actions: ["ses:SendEmail"], + resources: ["*"], }, ], - }, - permissions: [ - { - actions: ["ses:SendEmail"], - resources: ["*"], - }, - ], - dev: { - command: "bun dev:auth", - directory: "packages/functions", - url: "http://localhost:3002", - }, - scaling: - $app.stage === "production" - ? { - min: 2, - max: 10, - } - : undefined, - //For temporarily persisting the persist.json - transform: { - taskDefinition: (args) => { - const volumes = $output(args.volumes).apply(v => { - const next = [...v, { - name: "shared-tmp", - dockerVolumeConfiguration: { - scope: "shared", - driver: "local" - } - }]; - - return next; - }) - - // "containerDefinitions" is a JSON string, parse first - let containers = $jsonParse(args.containerDefinitions); - - containers = containers.apply((containerDefinitions) => { - containerDefinitions[0].mountPoints = [ - ...(containerDefinitions[0].mountPoints ?? []), - { - sourceVolume: "shared-tmp", - containerPath: "/tmp" - } - ] - return containerDefinitions; - }); - - args.volumes = volumes - args.containerDefinitions = $jsonStringify(containers); - } - } -}); - -export const auth = !$dev ? new sst.aws.Router("AuthRoute", { - routes: { - // I think auth.url should work all the same - "/*": authService.nodes.loadBalancer.dnsName, + handler: "packages/functions/src/auth/index.handler", }, domain: { name: "auth." + domain, dns: sst.cloudflare.dns(), }, -}) : authService \ No newline at end of file + forceUpgrade: "v2", +}); \ No newline at end of file diff --git a/infra/realtime.ts b/infra/realtime.ts index d2e14787..8f7b2bb6 100644 --- a/infra/realtime.ts +++ b/infra/realtime.ts @@ -1,9 +1,9 @@ -import { auth } from "./auth"; +// import { auth } from "./auth"; import { postgres } from "./postgres"; export const device = new sst.aws.Realtime("Realtime", { authorizer: { - link: [auth, postgres], + link: [ postgres], handler: "packages/functions/src/realtime/authorizer.handler" } }) \ No newline at end of file diff --git a/infra/zero.ts b/infra/zero.ts index 45ffcab4..ff8755a8 100644 --- a/infra/zero.ts +++ b/infra/zero.ts @@ -1,4 +1,3 @@ -import { vpc } from "./vpc"; import { auth } from "./auth"; import { domain } from "./dns"; import { readFileSync } from "fs"; @@ -6,7 +5,6 @@ import { cluster } from "./cluster"; import { storage } from "./storage"; import { postgres } from "./postgres"; -// const connectionString = $interpolate`postgresql://${postgres.username}:${postgres.password}@${postgres.host}/${postgres.database}` const connectionString = $interpolate`postgresql://${postgres.username}:${postgres.password}@${postgres.host}:${postgres.port}/${postgres.database}`; const tag = $dev @@ -43,12 +41,9 @@ const replicationManager = !$dev ? new sst.aws.Service(`ZeroReplication`, { cluster, wait: true, - ...($app.stage === "production" - ? { - cpu: "2 vCPU", - memory: "4 GB", - } - : {}), + cpu: "0.5 vCPU", + memory: "1 GB", + capacity: "spot", architecture: "arm64", image: zeroEnv.ZERO_IMAGE_URL, link: [storage, postgres], @@ -130,15 +125,9 @@ export const zero = new sst.aws.Service("Zero", { image: zeroEnv.ZERO_IMAGE_URL, link: [storage, postgres], architecture: "arm64", - ...($app.stage === "production" - ? { - cpu: "2 vCPU", - memory: "4 GB", - capacity: "spot" - } - : { - capacity: "spot" - }), + cpu: "0.5 vCPU", + memory: "1 GB", + capacity: "spot", environment: { ...zeroEnv, ...($dev diff --git a/packages/functions/package.json b/packages/functions/package.json index 6a1dc947..1f9a2785 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -7,10 +7,6 @@ "@types/bun": "latest", "@types/steamcommunity": "^3.43.8" }, - "scripts": { - "dev:auth": "bun run --watch ./src/auth/index.ts", - "dev:api": "bun run --watch ./src/api/index.ts" - }, "peerDependencies": { "typescript": "^5" }, diff --git a/packages/functions/src/api/index.ts b/packages/functions/src/api/index.ts index bc86bf97..b3ce3cad 100644 --- a/packages/functions/src/api/index.ts +++ b/packages/functions/src/api/index.ts @@ -1,16 +1,16 @@ import "zod-openapi/extend"; +import { Hono } from "hono"; import { cors } from "hono/cors"; import { GameApi } from "./game"; import { SteamApi } from "./steam"; import { auth } from "./utils/auth"; import { FriendApi } from "./friend"; import { logger } from "hono/logger"; -import { type Env, Hono } from "hono"; -import { Realtime } from "./realtime"; import { AccountApi } from "./account"; import { openAPISpecs } from "hono-openapi"; import { patchLogger } from "../utils/patch-logger"; import { HTTPException } from "hono/http-exception"; +import { handle, streamHandle } from "hono/aws-lambda"; import { ErrorCodes, VisibleError } from "@nestri/core/error"; patchLogger(); @@ -27,9 +27,8 @@ app const routes = app .get("/", (c) => c.text("Hello World!")) - .route("/games",GameApi.route) + .route("/games", GameApi.route) .route("/steam", SteamApi.route) - .route("/realtime", Realtime.route) .route("/friends", FriendApi.route) .route("/account", AccountApi.route) .onError((error, c) => { @@ -94,13 +93,6 @@ app.get( }), ); -export default { - port: 3001, - idleTimeout: 255, - webSocketHandler: Realtime.webSocketHandler, - fetch: (req: Request,env: Env) => - app.fetch(req, env, { - waitUntil: (fn) => fn, - passThroughOnException: () => { }, - }), -}; \ No newline at end of file +export type Routes = typeof routes; + +export const handler = process.env.SST_LIVE ? handle(app) : streamHandle(app); \ No newline at end of file diff --git a/packages/functions/src/auth/index.ts b/packages/functions/src/auth/index.ts index 0ea1283e..7a968684 100644 --- a/packages/functions/src/auth/index.ts +++ b/packages/functions/src/auth/index.ts @@ -1,24 +1,19 @@ import { Resource } from "sst"; -import { type Env } from "hono"; import { logger } from "hono/logger"; import { subjects } from "../subjects"; +import { handle } from "hono/aws-lambda"; import { PasswordUI, Select } from "./ui"; import { issuer } from "@openauthjs/openauth"; import { User } from "@nestri/core/user/index"; import { Email } from "@nestri/core/email/index"; import { patchLogger } from "../utils/patch-logger"; import { handleDiscord, handleGithub } from "./utils"; -import { MemoryStorage } from "@openauthjs/openauth/storage/memory"; import { DiscordAdapter, PasswordAdapter, GithubAdapter } from "./adapters"; patchLogger(); const app = issuer({ - //TODO: Create our own Storage (?) select: Select(), - storage: MemoryStorage({ - persist: process.env.STORAGE - }), theme: { title: "Nestri | Auth", primary: "#FF4F01", @@ -161,13 +156,4 @@ const app = issuer({ }, }).use(logger()) - -export default { - port: 3002, - idleTimeout: 255, - fetch: (req: Request, env: Env) => - app.fetch(req, env, { - waitUntil: (fn) => fn, - passThroughOnException: () => { }, - }), -}; \ No newline at end of file +export const handler = handle(app); \ No newline at end of file diff --git a/sst-env.d.ts b/sst-env.d.ts index 26f6384f..95b32621 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -7,13 +7,16 @@ import "sst" declare module "sst" { export interface Resource { "Api": { - "service": string - "type": "sst.aws.Service" + "type": "sst.aws.Router" + "url": string + } + "ApiFn": { + "name": string + "type": "sst.aws.Function" "url": string } "Auth": { - "service": string - "type": "sst.aws.Service" + "type": "sst.aws.Auth" "url": string } "Bus": {