mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +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:
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user