mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
🐜 fix: Fix an issue where ts-server is taking forever to load (#272)
## Description <!-- Briefly describe the purpose and scope of your changes --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Centralized and standardized error response schemas for APIs. - Utility functions for result formatting and enhanced validation error handling. - New utility modules for authentication and OAuth provider handling. - Added Discord OAuth user data fetching with email verification. - **Bug Fixes** - Improved error safety in cloud task creation by preventing potential runtime errors. - **Refactor** - Major simplification and reorganization of API routes and authentication logic. - Migration from valibot to zod for schema validation. - Streamlined import paths and consolidated utility exports. - Simplified TypeScript and .gitignore configuration for easier maintenance. - Disabled machine authentication provider and related logic. - **Chores** - Removal of unused or deprecated API endpoints, database migration, and permissions deployment code. - Updated package dependencies and scripts for improved reliability and performance. - Enhanced documentation and updated project metadata. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
173
packages/functions/.gitignore
vendored
173
packages/functions/.gitignore
vendored
@@ -1,175 +1,34 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# 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
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
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
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# 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.*
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# auth
|
||||
# @nestri/functions
|
||||
|
||||
To install dependencies:
|
||||
|
||||
@@ -12,4 +12,4 @@ To run:
|
||||
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.
|
||||
This project was created using `bun init` in bun v1.2.11. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
{
|
||||
"name": "@nestri/functions",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:auth": "bun run --watch ./src/auth.ts",
|
||||
"dev:auth": "bun run --watch ./src/auth/index.ts",
|
||||
"dev:api": "bun run --watch ./src/api/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-ecs": "^3.738.0",
|
||||
"@aws-sdk/client-sqs": "^3.734.0",
|
||||
"@cloudflare/workers-types": "^4.20241224.0",
|
||||
"@nestri/core": "*",
|
||||
"@types/bun": "latest",
|
||||
"valibot": "^1.0.0-beta.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actor-core/bun": "^0.7.9",
|
||||
"@openauthjs/openauth": "*",
|
||||
"actor-core": "^0.7.9",
|
||||
"hono": "^4.6.15",
|
||||
"hono-openapi": "^0.3.1",
|
||||
"partysocket": "1.0.3",
|
||||
"postgres": "^3.4.5"
|
||||
"@actor-core/bun": "^0.8.0",
|
||||
"@nestri/core":"workspace:",
|
||||
"actor-core": "^0.8.0",
|
||||
"hono": "^4.7.8",
|
||||
"hono-openapi": "^0.4.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { notPublic } from "./auth";
|
||||
import { notPublic } from "./utils/auth";
|
||||
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";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { ErrorResponses, Result } from "./common";
|
||||
import { ErrorResponses, Result } from "./utils";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
export namespace AccountApi {
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
import { z, ZodSchema } from "zod";
|
||||
import {type Hook } from "./types/hook";
|
||||
import { ErrorCodes, ErrorResponse } from "@nestri/core/error";
|
||||
import type { MiddlewareHandler, ValidationTargets } from "hono";
|
||||
import { resolver, validator as zodValidator } from "hono-openapi/zod";
|
||||
|
||||
export function Result<T extends z.ZodTypeAny>(schema: T) {
|
||||
return resolver(
|
||||
z.object({
|
||||
data: schema,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator wrapper around hono-openapi/zod validator that formats errors
|
||||
* according to our standard API error format
|
||||
*/
|
||||
export const validator = <
|
||||
T extends ZodSchema,
|
||||
Target extends keyof ValidationTargets
|
||||
>(
|
||||
target: Target,
|
||||
schema: T
|
||||
): MiddlewareHandler<
|
||||
any,
|
||||
string,
|
||||
{
|
||||
in: {
|
||||
[K in Target]: z.input<T>;
|
||||
};
|
||||
out: {
|
||||
[K in Target]: z.output<T>;
|
||||
};
|
||||
}
|
||||
> => {
|
||||
// Create a custom error handler that formats errors according to our standards
|
||||
// const standardErrorHandler: Parameters<typeof zodValidator>[2] = (
|
||||
const standardErrorHandler: Hook<z.infer<T>, any, any, Target> = (
|
||||
result,
|
||||
c,
|
||||
) => {
|
||||
if (!result.success) {
|
||||
// Get the validation issues
|
||||
const issues = result.error.issues || result.error.errors || [];
|
||||
if (issues.length === 0) {
|
||||
// If there are no issues, return a generic error
|
||||
return c.json(
|
||||
{
|
||||
type: "validation",
|
||||
code: ErrorCodes.Validation.INVALID_PARAMETER,
|
||||
message: "Invalid request data",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the first error for the main response
|
||||
const firstIssue = issues[0]!;
|
||||
const fieldPath = firstIssue.path
|
||||
? Array.isArray(firstIssue.path)
|
||||
? firstIssue.path.join(".")
|
||||
: firstIssue.path
|
||||
: undefined;
|
||||
|
||||
// Map Zod error codes to our standard error codes
|
||||
let errorCode = ErrorCodes.Validation.INVALID_PARAMETER;
|
||||
if (
|
||||
firstIssue.code === "invalid_type" &&
|
||||
firstIssue.received === "undefined"
|
||||
) {
|
||||
errorCode = ErrorCodes.Validation.MISSING_REQUIRED_FIELD;
|
||||
} else if (
|
||||
["invalid_string", "invalid_date", "invalid_regex"].includes(
|
||||
firstIssue.code,
|
||||
)
|
||||
) {
|
||||
errorCode = ErrorCodes.Validation.INVALID_FORMAT;
|
||||
}
|
||||
|
||||
// Create our standardized error response
|
||||
const response = {
|
||||
type: "validation",
|
||||
code: errorCode,
|
||||
message: firstIssue.message,
|
||||
param: fieldPath,
|
||||
details: undefined as any,
|
||||
};
|
||||
|
||||
// Add details if we have multiple issues
|
||||
if (issues.length > 0) {
|
||||
response.details = {
|
||||
issues: issues.map((issue) => ({
|
||||
path: issue.path
|
||||
? Array.isArray(issue.path)
|
||||
? issue.path.join(".")
|
||||
: issue.path
|
||||
: undefined,
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
// @ts-expect-error
|
||||
expected: issue.expected,
|
||||
// @ts-expect-error
|
||||
received: issue.received,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
console.log("Validation error in validator:", response);
|
||||
return c.json(response, 400);
|
||||
}
|
||||
};
|
||||
|
||||
// Use the original validator with our custom error handler
|
||||
return zodValidator(target, schema, standardErrorHandler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard error responses for OpenAPI documentation
|
||||
*/
|
||||
export const ErrorResponses = {
|
||||
400: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Validation error",
|
||||
example: {
|
||||
type: "validation",
|
||||
code: "invalid_parameter",
|
||||
message: "The request was invalid",
|
||||
param: "email",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Bad Request - The request could not be understood or was missing required parameters.",
|
||||
},
|
||||
401: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Authentication error",
|
||||
example: {
|
||||
type: "authentication",
|
||||
code: "unauthorized",
|
||||
message: "Authentication required",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Unauthorized - Authentication is required and has failed or has not been provided.",
|
||||
},
|
||||
403: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Permission error",
|
||||
example: {
|
||||
type: "forbidden",
|
||||
code: "permission_denied",
|
||||
message: "You do not have permission to access this resource",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Forbidden - You do not have permission to access this resource.",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Not found error",
|
||||
example: {
|
||||
type: "not_found",
|
||||
code: "resource_not_found",
|
||||
message: "The requested resource could not be found",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Not Found - The requested resource does not exist.",
|
||||
},
|
||||
409: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Conflict Error",
|
||||
example: {
|
||||
type: "already_exists",
|
||||
code: "resource_already_exists",
|
||||
message: "The resource could not be created because it already exists",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Conflict - The resource could not be created because it already exists.",
|
||||
},
|
||||
429: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Rate limit error",
|
||||
example: {
|
||||
type: "rate_limit",
|
||||
code: "too_many_requests",
|
||||
message: "Rate limit exceeded",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Too Many Requests - You have made too many requests in a short period of time.",
|
||||
},
|
||||
500: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Server error",
|
||||
example: {
|
||||
type: "internal",
|
||||
code: "internal_error",
|
||||
message: "Internal server error",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Internal Server Error - Something went wrong on our end.",
|
||||
},
|
||||
};
|
||||
@@ -1,15 +1,11 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Hono } from "hono";
|
||||
import { auth } from "./auth";
|
||||
import { cors } from "hono/cors";
|
||||
import { TeamApi } from "./team";
|
||||
import { PolarApi } from "./polar";
|
||||
import { logger } from "hono/logger";
|
||||
import { Realtime } from "./realtime";
|
||||
import { auth } from "./utils/auth";
|
||||
import { AccountApi } from "./account";
|
||||
import { MachineApi } from "./machine";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
import { patchLogger } from "../log-polyfill";
|
||||
import { patchLogger } from "../utils/patch-logger";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
@@ -26,10 +22,7 @@ app
|
||||
const routes = app
|
||||
.get("/", (c) => c.text("Hello World!"))
|
||||
.route("/realtime", Realtime.route)
|
||||
.route("/team", TeamApi.route)
|
||||
.route("/polar", PolarApi.route)
|
||||
.route("/account", AccountApi.route)
|
||||
.route("/machine", MachineApi.route)
|
||||
.onError((error, c) => {
|
||||
if (error instanceof VisibleError) {
|
||||
console.error("api error:", error);
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
import { z } from "zod"
|
||||
import { Hono } from "hono";
|
||||
import { notPublic } from "./auth";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { assertActor } from "@nestri/core/actor";
|
||||
import { ErrorResponses, Result } from "./common";
|
||||
import { Machine } from "@nestri/core/machine/index";
|
||||
import { Realtime } from "@nestri/core/realtime/index";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { CreateMessageSchema, StartMessageSchema, StopMessageSchema } from "./messages.ts";
|
||||
|
||||
export namespace MachineApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Get all BYOG machines",
|
||||
description: "All the BYOG machines owned by this user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Machine.Info.array().openapi({
|
||||
description: "All the user's BYOG machines",
|
||||
example: [Examples.Machine],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved all the user's machines",
|
||||
},
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const user = assertActor("user");
|
||||
const machineInfo = await Machine.fromUserID(user.properties.userID);
|
||||
|
||||
if (!machineInfo)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"No machines not found",
|
||||
);
|
||||
|
||||
return c.json({ data: machineInfo, }, 200);
|
||||
|
||||
})
|
||||
.get("/hosted",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Get all cloud machines",
|
||||
description: "All the machines that are connected to Nestri",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Machine.Info.array().openapi({
|
||||
description: "All the machines connected to Nestri",
|
||||
example: [{ ...Examples.Machine, userID: null }],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved all the hosted machines",
|
||||
},
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const machineInfo = await Machine.list();
|
||||
|
||||
if (!machineInfo)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"No machines not found",
|
||||
);
|
||||
|
||||
return c.json({ data: machineInfo, }, 200);
|
||||
|
||||
})
|
||||
.post("/",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Send messages to the machine",
|
||||
description: "Send messages directly to the machine",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.literal("ok")
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully sent the message to Maitred"
|
||||
},
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.any()
|
||||
),
|
||||
async (c) => {
|
||||
const actor = assertActor("machine");
|
||||
console.log("actor.id", actor.properties.machineID)
|
||||
|
||||
await Realtime.publish(c.req.valid("json"))
|
||||
|
||||
return c.json({
|
||||
data: "ok"
|
||||
}, 200);
|
||||
},
|
||||
)
|
||||
.post("/:machineID/create",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Request to create a container for a specific machine",
|
||||
description: "Publishes a message to create a container via MQTT for the given machine ID",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({
|
||||
message: z.literal("create request sent"),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Create request successfully sent to MQTT",
|
||||
},
|
||||
400: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({ error: z.string() })
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Failed to publish create request",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", CreateMessageSchema.shape.payload.optional()), // No payload required for create
|
||||
async (c) => {
|
||||
const actor = assertActor("machine");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const message = {
|
||||
type: "create" as const,
|
||||
payload: body || {}, // Empty payload if none provided
|
||||
};
|
||||
|
||||
try {
|
||||
await Realtime.publish(message, "create");
|
||||
console.log("Published create request to");
|
||||
} catch (error) {
|
||||
console.error("Failed to publish to MQTT:", error);
|
||||
return c.json({ error: "Failed to send create request" }, 400);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
message: "create request sent",
|
||||
},
|
||||
}, 200);
|
||||
}
|
||||
)
|
||||
.post("/:machineID/start",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Request to start a container for a specific machine",
|
||||
description: "Publishes a message to start a container via MQTT for the given machine ID",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({
|
||||
message: z.literal("start request sent"),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Start request successfully sent to MQTT",
|
||||
},
|
||||
400: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({ error: z.string() })
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Failed to publish start request",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", StartMessageSchema.shape.payload), // Use the payload schema
|
||||
async (c) => {
|
||||
const actor = assertActor("machine");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const message = {
|
||||
type: "start" as const,
|
||||
payload: {
|
||||
container_id: body.container_id,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await Realtime.publish(message, "start");
|
||||
console.log("Published start request");
|
||||
} catch (error) {
|
||||
console.error("Failed to publish to MQTT:", error);
|
||||
return c.json({ error: "Failed to send start request" }, 400);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
message: "start request sent",
|
||||
},
|
||||
}, 200);
|
||||
}
|
||||
)
|
||||
.post("/:machineID/stop",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Request to stop a container for a specific machine",
|
||||
description: "Publishes a message to stop a container via MQTT for the given machine ID",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({
|
||||
message: z.literal("stop request sent"),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Stop request successfully sent to MQTT",
|
||||
},
|
||||
400: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({ error: z.string() })
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Failed to publish start request",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", StopMessageSchema.shape.payload), // Use the payload schema
|
||||
async (c) => {
|
||||
const actor = assertActor("machine");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const message = {
|
||||
type: "stop" as const,
|
||||
payload: {
|
||||
container_id: body.container_id,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await Realtime.publish(message, "stop");
|
||||
console.log("Published stop request");
|
||||
} catch (error) {
|
||||
console.error("Failed to publish to MQTT:", error);
|
||||
return c.json({ error: "Failed to send stop request" }, 400);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
message: "stop request sent",
|
||||
},
|
||||
}, 200);
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
// Base message interface
|
||||
export interface BaseMessage {
|
||||
type: string; // e.g., "start", "stop", "status"
|
||||
payload: Record<string, any>; // Generic payload, refined by specific types
|
||||
}
|
||||
|
||||
// Specific message types
|
||||
export interface StartMessage extends BaseMessage {
|
||||
type: "start";
|
||||
payload: {
|
||||
container_id: string;
|
||||
[key: string]: any; // Allow additional fields for future expansion
|
||||
};
|
||||
}
|
||||
|
||||
// Example future message type
|
||||
export interface StopMessage extends BaseMessage {
|
||||
type: "stop";
|
||||
payload: {
|
||||
container_id: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
// Union type for all possible messages (expandable)
|
||||
export type MachineMessage = StartMessage | StopMessage; // Add more types as needed
|
||||
|
||||
// Zod schema for validation
|
||||
export const BaseMessageSchema = z.object({
|
||||
type: z.string(),
|
||||
payload: z.record(z.any()),
|
||||
});
|
||||
|
||||
export const CreateMessageSchema = BaseMessageSchema.extend({
|
||||
type: z.literal("create"),
|
||||
});
|
||||
|
||||
export const StartMessageSchema = BaseMessageSchema.extend({
|
||||
type: z.literal("start"),
|
||||
payload: z.object({
|
||||
container_id: z.string(),
|
||||
}).passthrough(),
|
||||
});
|
||||
|
||||
export const StopMessageSchema = BaseMessageSchema.extend({
|
||||
type: z.literal("stop"),
|
||||
payload: z.object({
|
||||
container_id: z.string(),
|
||||
}).passthrough(),
|
||||
});
|
||||
|
||||
export const MachineMessageSchema = z.union([StartMessageSchema, StopMessageSchema]);
|
||||
@@ -1,174 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Resource } from "sst";
|
||||
import { notPublic } from "./auth";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { assertActor } from "@nestri/core/actor";
|
||||
import { Polar } from "@nestri/core/polar/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { ErrorResponses, Result, validator } from "./common";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { PlanType } from "@nestri/core/subscription/subscription.sql";
|
||||
import { WebhookVerificationError, validateEvent } from "@polar-sh/sdk/webhooks";
|
||||
|
||||
export namespace PolarApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Polar"],
|
||||
summary: "Create a Polar.sh customer portal",
|
||||
description: "Creates Polar.sh's customer portal url where the user can manage their payments",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({
|
||||
portalUrl: z.string()
|
||||
}).openapi({
|
||||
description: "The customer portal url",
|
||||
example: { portalUrl: "https://polar.sh/portal/39393jdie09292" }
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "customer portal url"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const actor = assertActor("user");
|
||||
|
||||
const user = await User.fromID(actor.properties.userID);
|
||||
|
||||
if (!user)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"User not found",
|
||||
);
|
||||
|
||||
if (!user.polarCustomerID)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"User does not contain Polar customer ID"
|
||||
)
|
||||
|
||||
const portalUrl = await Polar.createPortal(user.polarCustomerID)
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
portalUrl
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
.post("/checkout",
|
||||
describeRoute({
|
||||
tags: ["Polar"],
|
||||
summary: "Create a checkout url",
|
||||
description: "Creates a Polar.sh's checkout url for the user to pay a subscription for this team",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({
|
||||
checkoutUrl: z.string()
|
||||
}).openapi({
|
||||
description: "The checkout url",
|
||||
example: { checkoutUrl: "https://polar.sh/portal/39393jdie09292" }
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "checkout url"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z
|
||||
.object({
|
||||
planType: z.enum(PlanType),
|
||||
successUrl: z.string().url("Success url must be a valid url")
|
||||
})
|
||||
.openapi({
|
||||
description: "Details of the team to create",
|
||||
example: {
|
||||
planType: Examples.Subscription.planType,
|
||||
successUrl: "https://your-url.io/thanks"
|
||||
},
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const actor = assertActor("user");
|
||||
|
||||
const user = await User.fromID(actor.properties.userID);
|
||||
|
||||
if (!user)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"User not found",
|
||||
);
|
||||
|
||||
if (!user.polarCustomerID)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"User does not contain Polar customer ID"
|
||||
)
|
||||
|
||||
const checkoutUrl = await Polar.createCheckout({ customerID: user.polarCustomerID, planType: body.planType, successUrl: body.successUrl })
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
checkoutUrl,
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
.post("/webhook",
|
||||
async (c) => {
|
||||
const requestBody = await c.req.text();
|
||||
|
||||
const webhookSecret = Resource.PolarWebhookSecret.value
|
||||
|
||||
const webhookHeaders = {
|
||||
"webhook-id": c.req.header("webhook-id") ?? "",
|
||||
"webhook-timestamp": c.req.header("webhook-timestamp") ?? "",
|
||||
"webhook-signature": c.req.header("webhook-signature") ?? "",
|
||||
};
|
||||
|
||||
let webhookPayload: ReturnType<typeof validateEvent>;
|
||||
try {
|
||||
webhookPayload = validateEvent(
|
||||
requestBody,
|
||||
webhookHeaders,
|
||||
webhookSecret,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WebhookVerificationError) {
|
||||
return c.json({ received: false }, { status: 403 });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
await Polar.handleWebhook(webhookPayload)
|
||||
|
||||
return c.json({ received: true });
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { notPublic } from "./auth";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { assertActor } from "@nestri/core/actor";
|
||||
import { ErrorResponses, Result } from "./common";
|
||||
import { Subscription } from "@nestri/core/subscription/index";
|
||||
|
||||
export namespace SubscriptionApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Subscription"],
|
||||
summary: "Get user subscriptions",
|
||||
description: "Get all user subscriptions",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Subscription.Info.array().openapi({
|
||||
description: "All the subscriptions this user has",
|
||||
example: [Examples.Subscription]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "All user subscriptions"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const actor = assertActor("user")
|
||||
|
||||
const subscriptions = await Subscription.fromUserID(actor.properties.userID)
|
||||
|
||||
return c.json({
|
||||
data: subscriptions
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { notPublic } from "./auth";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Polar } from "@nestri/core/polar/index";
|
||||
import { Member } from "@nestri/core/member/index";
|
||||
import { assertActor, withActor } from "@nestri/core/actor";
|
||||
import { ErrorResponses, Result, validator } from "./common";
|
||||
import { Subscription } from "@nestri/core/subscription/index";
|
||||
import { PlanType } from "@nestri/core/subscription/subscription.sql";
|
||||
|
||||
export namespace TeamApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Team"],
|
||||
summary: "List teams",
|
||||
description: "List the teams associated with the current user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Team.Info.array().openapi({
|
||||
description: "List of teams",
|
||||
example: [Examples.Team]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "List of teams"
|
||||
},
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({
|
||||
data: await User.teams()
|
||||
}, 200);
|
||||
},
|
||||
)
|
||||
.post("/",
|
||||
describeRoute({
|
||||
tags: ["Team"],
|
||||
summary: "Create a team",
|
||||
description: "Create a team for the current user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({
|
||||
checkoutUrl: z.string().openapi({
|
||||
description: "The checkout url to confirm subscription for this team",
|
||||
example: "https://polar.sh/checkout/2903038439320298377"
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: "Team created succesfully"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
409: ErrorResponses[409],
|
||||
429: ErrorResponses[429],
|
||||
500: ErrorResponses[500],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
Team.create.schema
|
||||
.pick({ slug: true, name: true })
|
||||
.extend({ planType: z.enum(PlanType), successUrl: z.string().url("Success url must be a valid url") })
|
||||
.openapi({
|
||||
description: "Details of the team to create",
|
||||
example: {
|
||||
slug: Examples.Team.slug,
|
||||
name: Examples.Team.name,
|
||||
planType: Examples.Subscription.planType,
|
||||
successUrl: "https://your-url.io/thanks"
|
||||
},
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const actor = assertActor("user");
|
||||
|
||||
const teamID = await Team.create({ name: body.name, slug: body.slug });
|
||||
|
||||
await withActor(
|
||||
{
|
||||
type: "system",
|
||||
properties: {
|
||||
teamID,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
await Member.create({
|
||||
first: true,
|
||||
email: actor.properties.email,
|
||||
});
|
||||
|
||||
await Subscription.create({
|
||||
planType: body.planType,
|
||||
userID: actor.properties.userID,
|
||||
// FIXME: Make this make sense
|
||||
tokens: body.planType === "free" ? 100 : body.planType === "pro" ? 1000 : body.planType === "family" ? 10000 : 0,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const checkoutUrl = await Polar.createCheckout({ planType: body.planType, successUrl: body.successUrl, teamID })
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
checkoutUrl,
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Resource } from "sst";
|
||||
import { subjects } from "../subjects";
|
||||
import { subjects } from "../../subjects";
|
||||
import { type MiddlewareHandler } from "hono";
|
||||
import { useActor, withActor } from "@nestri/core/actor";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
const client = createClient({
|
||||
issuer: Resource.Auth.url,
|
||||
clientID: "api",
|
||||
issuer: Resource.Auth.url,
|
||||
});
|
||||
|
||||
export const notPublic: MiddlewareHandler = async (c, next) => {
|
||||
129
packages/functions/src/api/utils/error.ts
Normal file
129
packages/functions/src/api/utils/error.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ErrorResponse } from "@nestri/core/error";
|
||||
|
||||
export const ErrorResponses = {
|
||||
400: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Validation error",
|
||||
example: {
|
||||
type: "validation",
|
||||
code: "invalid_parameter",
|
||||
message: "The request was invalid",
|
||||
param: "email",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Bad Request - The request could not be understood or was missing required parameters.",
|
||||
},
|
||||
401: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Authentication error",
|
||||
example: {
|
||||
type: "authentication",
|
||||
code: "unauthorized",
|
||||
message: "Authentication required",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Unauthorized - Authentication is required and has failed or has not been provided.",
|
||||
},
|
||||
403: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Permission error",
|
||||
example: {
|
||||
type: "forbidden",
|
||||
code: "permission_denied",
|
||||
message: "You do not have permission to access this resource",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Forbidden - You do not have permission to access this resource.",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Not found error",
|
||||
example: {
|
||||
type: "not_found",
|
||||
code: "resource_not_found",
|
||||
message: "The requested resource could not be found",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Not Found - The requested resource does not exist.",
|
||||
},
|
||||
409: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Conflict Error",
|
||||
example: {
|
||||
type: "already_exists",
|
||||
code: "resource_already_exists",
|
||||
message: "The resource could not be created because it already exists",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Conflict - The resource could not be created because it already exists.",
|
||||
},
|
||||
429: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Rate limit error",
|
||||
example: {
|
||||
type: "rate_limit",
|
||||
code: "too_many_requests",
|
||||
message: "Rate limit exceeded",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Too Many Requests - You have made too many requests in a short period of time.",
|
||||
},
|
||||
500: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Server error",
|
||||
example: {
|
||||
type: "internal",
|
||||
code: "internal_error",
|
||||
message: "Internal server error",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Internal Server Error - Something went wrong on our end.",
|
||||
},
|
||||
};
|
||||
@@ -17,4 +17,4 @@ declare const zValidator: <T extends ZodSchema<any, z.ZodTypeDef, any>, Target e
|
||||
out: { [K_2 in Target]: Out; };
|
||||
}, V extends I = I>(target: Target, schema: T, hook?: Hook<z.TypeOf<T>, E, P, Target, {}> | undefined) => MiddlewareHandler<E, P, V>;
|
||||
|
||||
export { type Hook, zValidator };
|
||||
export { type Hook, zValidator };
|
||||
3
packages/functions/src/api/utils/index.ts
Normal file
3
packages/functions/src/api/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./validator";
|
||||
export * from "./result";
|
||||
export * from "./error";
|
||||
6
packages/functions/src/api/utils/result.ts
Normal file
6
packages/functions/src/api/utils/result.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
|
||||
export function Result<T extends z.ZodTypeAny>(schema: T) {
|
||||
return resolver(z.object({ data: schema }));
|
||||
}
|
||||
77
packages/functions/src/api/utils/validator.ts
Normal file
77
packages/functions/src/api/utils/validator.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Hook } from "./hook";
|
||||
import { z, ZodSchema } from "zod";
|
||||
import { ErrorCodes } from "@nestri/core/error";
|
||||
import { validator as zodValidator } from "hono-openapi/zod";
|
||||
import type { MiddlewareHandler, ValidationTargets } from "hono";
|
||||
|
||||
type ZodIssueExtended = z.ZodIssue & {
|
||||
expected?: unknown;
|
||||
received?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator wrapper around hono-openapi/zod validator that formats errors
|
||||
*/
|
||||
export const validator = <
|
||||
T extends ZodSchema,
|
||||
Target extends keyof ValidationTargets
|
||||
>(
|
||||
target: Target,
|
||||
schema: T
|
||||
): MiddlewareHandler<
|
||||
Record<string, unknown>,
|
||||
string,
|
||||
{
|
||||
in: {
|
||||
[K in Target]: z.input<T>;
|
||||
};
|
||||
out: {
|
||||
[K in Target]: z.output<T>;
|
||||
};
|
||||
}
|
||||
> => {
|
||||
const standardErrorHandler: Hook<z.infer<T>, any, any, Target> = (
|
||||
result,
|
||||
c,
|
||||
) => {
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues || result.error.errors || [];
|
||||
const firstIssue = issues[0];
|
||||
const fieldPath = Array.isArray(firstIssue?.path)
|
||||
? firstIssue.path.join(".")
|
||||
: firstIssue?.path;
|
||||
|
||||
let errorCode = ErrorCodes.Validation.INVALID_PARAMETER;
|
||||
if (firstIssue?.code === "invalid_type" && firstIssue?.received === "undefined") {
|
||||
errorCode = ErrorCodes.Validation.MISSING_REQUIRED_FIELD;
|
||||
} else if (
|
||||
["invalid_string", "invalid_date", "invalid_regex"].includes(firstIssue?.code as string)
|
||||
) {
|
||||
errorCode = ErrorCodes.Validation.INVALID_FORMAT;
|
||||
}
|
||||
|
||||
const response = {
|
||||
type: "validation",
|
||||
code: errorCode,
|
||||
message: firstIssue?.message,
|
||||
param: fieldPath,
|
||||
details: issues.length > 1
|
||||
? {
|
||||
issues: issues.map((issue: ZodIssueExtended) => ({
|
||||
path: Array.isArray(issue.path) ? issue.path.join(".") : issue.path,
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
expected: issue.expected,
|
||||
received: issue.received,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
console.log("Validation error in validator:", response);
|
||||
return c.json(response, 400);
|
||||
}
|
||||
};
|
||||
|
||||
return zodValidator(target, schema, standardErrorHandler);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Oauth2Adapter, type Oauth2WrappedConfig } from "./oauth2"
|
||||
import { Oauth2Adapter, type Oauth2WrappedConfig } from "../ui/oauth2"
|
||||
|
||||
export function DiscordAdapter(config: Oauth2WrappedConfig) {
|
||||
return Oauth2Adapter({
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Oauth2Adapter, type Oauth2WrappedConfig } from "./oauth2"
|
||||
import { Oauth2Adapter, type Oauth2WrappedConfig } from "../ui/oauth2"
|
||||
|
||||
export function GithubAdapter(config: Oauth2WrappedConfig) {
|
||||
return Oauth2Adapter({
|
||||
3
packages/functions/src/auth/adapters/index.ts
Normal file
3
packages/functions/src/auth/adapters/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./discord"
|
||||
export * from "./github"
|
||||
export * from "./password"
|
||||
@@ -1,19 +1,16 @@
|
||||
import { Resource } from "sst"
|
||||
import { Select } from "./ui/select";
|
||||
import { subjects } from "./subjects"
|
||||
import { logger } from "hono/logger";
|
||||
import { PasswordUI } from "./ui/password"
|
||||
import { patchLogger } from "./log-polyfill";
|
||||
import { subjects } from "../subjects"
|
||||
import { Select, PasswordUI } from "./ui";
|
||||
import { issuer } from "@openauthjs/openauth";
|
||||
import { User } from "@nestri/core/user/index"
|
||||
import { Email } from "@nestri/core/email/index";
|
||||
// import { Email } from "@nestri/core/email/index";
|
||||
// import { Machine } from "@nestri/core/machine/index"
|
||||
import { patchLogger } from "../utils/patch-logger";
|
||||
import { handleDiscord, handleGithub } from "./utils";
|
||||
import { GithubAdapter } from "./ui/adapters/github";
|
||||
import { Machine } from "@nestri/core/machine/index"
|
||||
import { DiscordAdapter } from "./ui/adapters/discord";
|
||||
import { PasswordAdapter } from "./ui/adapters/password";
|
||||
import { type Provider } from "@openauthjs/openauth/provider/provider"
|
||||
import { MemoryStorage } from "@openauthjs/openauth/storage/memory";
|
||||
// import { type Provider } from "@openauthjs/openauth/provider/provider"
|
||||
import { DiscordAdapter, PasswordAdapter, GithubAdapter } from "./adapters";
|
||||
|
||||
type OauthUser = {
|
||||
primary: {
|
||||
@@ -80,25 +77,25 @@ const app = issuer({
|
||||
},
|
||||
}),
|
||||
),
|
||||
machine: {
|
||||
type: "machine",
|
||||
async client(input) {
|
||||
// FIXME: Do we really need this?
|
||||
// if (input.clientSecret !== Resource.AuthFingerprintKey.value) {
|
||||
// throw new Error("Invalid authorization token");
|
||||
// }
|
||||
// machine: {
|
||||
// type: "machine",
|
||||
// async client(input) {
|
||||
// // FIXME: Do we really need this?
|
||||
// // if (input.clientSecret !== Resource.AuthFingerprintKey.value) {
|
||||
// // throw new Error("Invalid authorization token");
|
||||
// // }
|
||||
|
||||
const fingerprint = input.params.fingerprint;
|
||||
if (!fingerprint) {
|
||||
throw new Error("Hostname is required");
|
||||
}
|
||||
// const fingerprint = input.params.fingerprint;
|
||||
// if (!fingerprint) {
|
||||
// throw new Error("Hostname is required");
|
||||
// }
|
||||
|
||||
return {
|
||||
fingerprint,
|
||||
};
|
||||
},
|
||||
init() { }
|
||||
} as Provider<{ fingerprint: string; }>,
|
||||
// return {
|
||||
// fingerprint,
|
||||
// };
|
||||
// },
|
||||
// init() { }
|
||||
// } as Provider<{ fingerprint: string; }>,
|
||||
},
|
||||
allow: async (input) => {
|
||||
const url = new URL(input.redirectURI);
|
||||
@@ -108,39 +105,40 @@ const app = issuer({
|
||||
return false;
|
||||
},
|
||||
success: async (ctx, value, req) => {
|
||||
if (value.provider === "machine") {
|
||||
const countryCode = req.headers.get('CloudFront-Viewer-Country') || 'Unknown'
|
||||
const country = req.headers.get('CloudFront-Viewer-Country-Name') || 'Unknown'
|
||||
const latitude = Number(req.headers.get('CloudFront-Viewer-Latitude')) || 0
|
||||
const longitude = Number(req.headers.get('CloudFront-Viewer-Longitude')) || 0
|
||||
const timezone = req.headers.get('CloudFront-Viewer-Time-Zone') || 'Unknown'
|
||||
const fingerprint = value.fingerprint
|
||||
// I dunno what i broke... will check later
|
||||
// if (value.provider === "machine") {
|
||||
// const countryCode = req.headers.get('CloudFront-Viewer-Country') || 'Unknown'
|
||||
// const country = req.headers.get('CloudFront-Viewer-Country-Name') || 'Unknown'
|
||||
// const latitude = Number(req.headers.get('CloudFront-Viewer-Latitude')) || 0
|
||||
// const longitude = Number(req.headers.get('CloudFront-Viewer-Longitude')) || 0
|
||||
// const timezone = req.headers.get('CloudFront-Viewer-Time-Zone') || 'Unknown'
|
||||
// const fingerprint = value.fingerprint
|
||||
|
||||
const existing = await Machine.fromFingerprint(fingerprint)
|
||||
if (!existing) {
|
||||
const machineID = await Machine.create({
|
||||
countryCode,
|
||||
country,
|
||||
fingerprint,
|
||||
timezone,
|
||||
location: {
|
||||
latitude,
|
||||
longitude
|
||||
},
|
||||
//FIXME: Make this better
|
||||
// userID: null
|
||||
})
|
||||
return ctx.subject("machine", {
|
||||
machineID,
|
||||
fingerprint
|
||||
});
|
||||
}
|
||||
// const existing = await Machine.fromFingerprint(fingerprint)
|
||||
// if (!existing) {
|
||||
// const machineID = await Machine.create({
|
||||
// countryCode,
|
||||
// country,
|
||||
// fingerprint,
|
||||
// timezone,
|
||||
// location: {
|
||||
// latitude,
|
||||
// longitude
|
||||
// },
|
||||
// //FIXME: Make this better
|
||||
// // userID: null
|
||||
// })
|
||||
// return ctx.subject("machine", {
|
||||
// machineID,
|
||||
// fingerprint
|
||||
// });
|
||||
// }
|
||||
|
||||
return ctx.subject("machine", {
|
||||
machineID: existing.id,
|
||||
fingerprint
|
||||
});
|
||||
}
|
||||
// return ctx.subject("machine", {
|
||||
// machineID: existing.id,
|
||||
// fingerprint
|
||||
// });
|
||||
// }
|
||||
|
||||
// TODO: This works, so use this while registering the task
|
||||
// console.log("country_code", req.headers.get('CloudFront-Viewer-Country'))
|
||||
2
packages/functions/src/auth/ui/index.ts
Normal file
2
packages/functions/src/auth/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./password"
|
||||
export * from "./select"
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
import { Layout } from "../base"
|
||||
import fetch from "node-fetch"
|
||||
import { Layout } from "./base"
|
||||
import { OauthError } from "@openauthjs/openauth/error"
|
||||
import { getRelativeUrl } from "@openauthjs/openauth/util"
|
||||
import { type Provider } from "@openauthjs/openauth/provider/provider"
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type PasswordConfig,
|
||||
type PasswordLoginError,
|
||||
type PasswordRegisterError,
|
||||
} from "./adapters/password"
|
||||
} from "../adapters"
|
||||
// import { Layout } from "@openauthjs/openauth/ui/base"
|
||||
import { Layout } from "./base"
|
||||
import "@openauthjs/openauth/ui/form"
|
||||
38
packages/functions/src/auth/utils/discord.ts
Normal file
38
packages/functions/src/auth/utils/discord.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import fetch from "node-fetch"
|
||||
|
||||
export const handleDiscord = async (accessKey: string) => {
|
||||
try {
|
||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const user = await response.json();
|
||||
// console.log("raw user", user)
|
||||
if (!user.verified) {
|
||||
throw new Error("Email not verified");
|
||||
}
|
||||
|
||||
return {
|
||||
primary: {
|
||||
email: user.email,
|
||||
verified: user.verified,
|
||||
primary: true
|
||||
},
|
||||
avatar: user.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`
|
||||
: null,
|
||||
username: user.global_name ?? user.username
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Discord OAuth error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import fetch from "node-fetch";
|
||||
|
||||
export const handleGithub = async (accessKey: string) => {
|
||||
console.log("acceskey", accessKey)
|
||||
|
||||
@@ -37,41 +39,4 @@ export const handleGithub = async (accessKey: string) => {
|
||||
console.error('GitHub OAuth error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const handleDiscord = async (accessKey: string) => {
|
||||
try {
|
||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const user = await response.json();
|
||||
// console.log("raw user", user)
|
||||
if (!user.verified) {
|
||||
throw new Error("Email not verified");
|
||||
}
|
||||
|
||||
return {
|
||||
primary: {
|
||||
email: user.email,
|
||||
verified: user.verified,
|
||||
primary: true
|
||||
},
|
||||
avatar: user.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`
|
||||
: null,
|
||||
username: user.global_name ?? user.username
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Discord OAuth error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
}
|
||||
2
packages/functions/src/auth/utils/index.ts
Normal file
2
packages/functions/src/auth/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./discord"
|
||||
export * from "./github"
|
||||
@@ -1,8 +0,0 @@
|
||||
import { db } from "@nestri/core/drizzle/index";
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||
|
||||
export const handler = async (event: any) => {
|
||||
await migrate(db, {
|
||||
migrationsFolder: "./migrations",
|
||||
});
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Resource } from "sst";
|
||||
import { subjects } from "../subjects";
|
||||
import { realtime } from "sst/aws/realtime";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
|
||||
export const handler = realtime.authorizer(async (token) => {
|
||||
//TODO: Use the following criteria for a topic - team-slug/container-id (container ids are not unique globally)
|
||||
//TODO: Allow the authorizer to subscriber/publisher to listen on - team-slug topics only (as the container will listen on the team-slug/container-id topic to be specific)
|
||||
// Return the topics to subscribe and publish
|
||||
|
||||
const client = createClient({
|
||||
clientID: "api",
|
||||
issuer: Resource.Auth.url
|
||||
});
|
||||
|
||||
const result = await client.verify(subjects, token);
|
||||
|
||||
if (result.err) {
|
||||
console.log("error", result.err)
|
||||
return {
|
||||
subscribe: [],
|
||||
publish: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (result.subject.type != "user") {
|
||||
return {
|
||||
subscribe: [],
|
||||
publish: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
//It can publish and listen to other instances under this team
|
||||
subscribe: [`${Resource.App.name}/${Resource.App.stage}/*`],
|
||||
publish: [`${Resource.App.name}/${Resource.App.stage}/*`],
|
||||
};
|
||||
});
|
||||
@@ -40,9 +40,9 @@ export const handler = async (event: any) => {
|
||||
|
||||
// Extract task details
|
||||
const task = runResponse.tasks[0];
|
||||
const taskArn = task.taskArn!;
|
||||
const taskArn = task?.taskArn!;
|
||||
const taskId = taskArn.split('/').pop()!; // Extract task ID from ARN
|
||||
const taskStatus = task.lastStatus!;
|
||||
const taskStatus = task?.lastStatus!;
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as v from "valibot"
|
||||
import { z } from "zod"
|
||||
import { createSubjects } from "@openauthjs/openauth/subject"
|
||||
|
||||
export const subjects = createSubjects({
|
||||
user: v.object({
|
||||
email: v.string(),
|
||||
userID: v.string(),
|
||||
}),
|
||||
machine: v.object({
|
||||
fingerprint: v.string(),
|
||||
machineID: v.string(),
|
||||
})
|
||||
user: z.object({
|
||||
email: z.string(),
|
||||
userID: z.string(),
|
||||
}),
|
||||
machine: z.object({
|
||||
fingerprint: z.string(),
|
||||
machineID: z.string(),
|
||||
})
|
||||
})
|
||||
@@ -1,4 +0,0 @@
|
||||
export enum Subscription {
|
||||
Pro = "Pro",
|
||||
Free = "Free"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
// import postgres from "postgres";
|
||||
import { db, sql } from "@nestri/core/drizzle/index";
|
||||
|
||||
export async function handler() {
|
||||
// const sql = postgres(process.env.ZERO_UPSTREAM_DB!);
|
||||
const perms = fs.readFileSync(".permissions.sql", "utf8");
|
||||
// await sql.unsafe(perms);
|
||||
await db.execute(sql.raw(perms))
|
||||
}
|
||||
@@ -1,27 +1,10 @@
|
||||
{
|
||||
"extends": "@tsconfig/node20/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"module": "esnext",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
"noUncheckedIndexedAccess": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user