🐜 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:
Wanjohi
2025-05-06 05:22:26 +03:00
committed by GitHub
parent 47e61599bb
commit a0dc353561
43 changed files with 502 additions and 1391 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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"
}
}
}

View File

@@ -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 {

View File

@@ -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.",
},
};

View File

@@ -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);

View File

@@ -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);
}
)
}

View File

@@ -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]);

View File

@@ -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 });
}
)
}

View File

@@ -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
})
}
)
}

View File

@@ -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,
}
})
}
)
}

View File

@@ -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) => {

View 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.",
},
};

View File

@@ -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 };

View File

@@ -0,0 +1,3 @@
export * from "./validator";
export * from "./result";
export * from "./error";

View 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 }));
}

View 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);
};

View File

@@ -1,4 +1,4 @@
import { Oauth2Adapter, type Oauth2WrappedConfig } from "./oauth2"
import { Oauth2Adapter, type Oauth2WrappedConfig } from "../ui/oauth2"
export function DiscordAdapter(config: Oauth2WrappedConfig) {
return Oauth2Adapter({

View File

@@ -1,4 +1,4 @@
import { Oauth2Adapter, type Oauth2WrappedConfig } from "./oauth2"
import { Oauth2Adapter, type Oauth2WrappedConfig } from "../ui/oauth2"
export function GithubAdapter(config: Oauth2WrappedConfig) {
return Oauth2Adapter({

View File

@@ -0,0 +1,3 @@
export * from "./discord"
export * from "./github"
export * from "./password"

View File

@@ -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'))

View File

@@ -0,0 +1,2 @@
export * from "./password"
export * from "./select"

View File

@@ -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"

View File

@@ -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"

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
export * from "./discord"
export * from "./github"

View File

@@ -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",
});
};

View File

@@ -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}/*`],
};
});

View File

@@ -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,

View File

@@ -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(),
})
})

View File

@@ -1,4 +0,0 @@
export enum Subscription {
Pro = "Pro",
Free = "Free"
}

View File

@@ -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))
}

View File

@@ -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
}
}
}