fix: Move more directories

This commit is contained in:
Wanjohi
2025-09-06 16:50:44 +03:00
parent 1c1c73910b
commit 9818165a90
248 changed files with 9 additions and 9566 deletions

View File

@@ -0,0 +1,40 @@
import { Hono } from "hono";
import { notPublic } from "./utils";
import { describeRoute } from "hono-openapi";
import { Examples } from "@nestri/core/examples";
import { ErrorResponses, Result } from "./utils";
import { Account } from "@nestri/core/account/index";
export namespace AccountApi {
export const route = new Hono()
.use(notPublic)
.get("/",
describeRoute({
tags: ["Account"],
summary: "Get user account",
description: "Get the current user's account details",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Account.Info.openapi({
description: "User account information",
example: { ...Examples.User, profiles: [Examples.SteamAccount] }
})
),
},
},
description: "User account details"
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429],
}
}),
async (c) =>
c.json({
data: await Account.list()
}, 200)
)
}

View File

@@ -0,0 +1,93 @@
import { z } from "zod"
import { Hono } from "hono";
import { validator } from "hono-openapi/zod";
import { describeRoute } from "hono-openapi";
import { Examples } from "@nestri/core/examples";
import { Friend } from "@nestri/core/friend/index";
import { ErrorResponses, notPublic, Result } from "./utils";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
export namespace FriendApi {
export const route = new Hono()
.use(notPublic)
.get("/",
describeRoute({
tags: ["Friend"],
summary: "List friends accounts",
description: "List all this user's friends accounts",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Friend.Info.array().openapi({
description: "All friends accounts",
example: [Examples.Friend]
})
),
},
},
description: "Friends accounts details"
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429],
}
}),
async (c) =>
c.json({
data: await Friend.list()
})
)
.get("/:id",
describeRoute({
tags: ["Friend"],
summary: "Get a friend",
description: "Get a friend's details by their SteamID",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Friend.Info.openapi({
description: "Friend's accounts",
example: Examples.Friend
})
),
},
},
description: "Friends accounts details"
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429],
}
}),
validator(
"param",
z.object({
id: z.string().openapi({
description: "ID of the friend to get",
example: Examples.Friend.id,
}),
}),
),
async (c) => {
const friendSteamID = c.req.valid("param").id
const friend = await Friend.fromFriendID(friendSteamID)
if (!friend) {
throw new VisibleError(
"not_found",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`Friend ${friendSteamID} not found`
)
}
return c.json({
data: friend
})
}
)
}

View File

@@ -0,0 +1,92 @@
import { z } from "zod"
import { Hono } from "hono";
import { describeRoute } from "hono-openapi";
import { Game } from "@nestri/core/game/index";
import { Examples } from "@nestri/core/examples";
import { Library } from "@nestri/core/library/index";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
import { ErrorResponses, notPublic, Result, validator } from "./utils";
export namespace GameApi {
export const route = new Hono()
.use(notPublic)
.get("/",
describeRoute({
tags: ["Game"],
summary: "List games",
description: "List all the games on this user's library",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Game.Info.array().openapi({
description: "All games in the library",
example: [Examples.Game]
})
),
},
},
description: "All games in the library"
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429],
}
}),
async (c) =>
c.json({
data: await Library.list()
})
)
.get("/:id",
describeRoute({
tags: ["Game"],
summary: "Get game",
description: "Get a game by its id, it does not have to be in user's library",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Game.Info.openapi({
description: "Game details",
example: Examples.Game
})
),
},
},
description: "Game details"
},
400: ErrorResponses[400],
429: ErrorResponses[429],
}
}),
validator(
"param",
z.object({
id: z.string().openapi({
description: "ID of the game to get",
example: Examples.Game.id,
}),
}),
),
async (c) => {
const gameID = c.req.valid("param").id
const game = await Game.fromID(gameID)
if (!game) {
throw new VisibleError(
"not_found",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`Game ${gameID} does not exist`
)
}
return c.json({
data: game
})
}
)
}

View File

@@ -0,0 +1,96 @@
import "zod-openapi/extend";
import { Hono } from "hono";
import { GameApi } from "./game";
import { SteamApi } from "./steam";
import { auth } from "./utils/auth";
import { FriendApi } from "./friend";
import { logger } from "hono/logger";
import { AccountApi } from "./account";
import { openAPISpecs } from "hono-openapi";
import { patchLogger } from "../utils/patch-logger";
import { HTTPException } from "hono/http-exception";
import { handle, streamHandle } from "hono/aws-lambda";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
patchLogger();
export const app = new Hono();
app
.use(logger())
.use(async (c, next) => {
c.header("Cache-Control", "no-store");
return next();
})
.use(auth)
const routes = app
.get("/", (c) => c.text("Hello World!"))
.route("/games", GameApi.route)
.route("/steam", SteamApi.route)
.route("/friends", FriendApi.route)
.route("/account", AccountApi.route)
.onError((error, c) => {
if (error instanceof VisibleError) {
console.error("api error:", error);
// @ts-expect-error
return c.json(error.toResponse(), error.statusCode());
}
// Handle HTTP exceptions
if (error instanceof HTTPException) {
console.error("http error:", error);
return c.json(
{
type: "validation",
code: ErrorCodes.Validation.INVALID_PARAMETER,
message: "Invalid request",
},
error.status,
);
}
console.error("unhandled error:", error);
return c.json(
{
type: "internal",
code: ErrorCodes.Server.INTERNAL_ERROR,
message: "Internal server error",
},
500,
);
});
app.get(
"/doc",
openAPISpecs(routes, {
documentation: {
info: {
title: "Nestri API",
description: "The Nestri API gives you the power to run your own customized cloud gaming platform.",
version: "0.0.1",
},
components: {
securitySchemes: {
Bearer: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
TeamID: {
type: "apiKey",
description: "The steam ID to use for this query",
in: "header",
name: "x-nestri-steam"
},
},
},
security: [{ Bearer: [], TeamID: [] }],
servers: [
{ description: "Production", url: "https://api.nestri.io" },
{ description: "Sandbox", url: "https://api.dev.nestri.io" },
],
},
}),
);
export type Routes = typeof routes;
export const handler = process.env.SST_LIVE ? handle(app) : streamHandle(app);

View File

@@ -0,0 +1,28 @@
import { actor } from "actor-core";
// Define a chat room actor
const chatRoom = actor({
// Initialize state when the actor is first created
createState: () => ({
messages: [] as any[],
}),
// Define actions clients can call
actions: {
// Action to send a message
sendMessage: (c, sender, text) => {
// Update state
c.state.messages.push({ sender, text });
// Broadcast to all connected clients
c.broadcast("newMessage", { sender, text });
},
// Action to get chat history
getHistory: (c) => {
return c.state.messages;
}
}
});
export default chatRoom;

View File

@@ -0,0 +1,28 @@
import { setup } from "actor-core";
import chatRoom from "./actor-core";
import { createRouter } from "@actor-core/bun";
import {
FileSystemGlobalState,
FileSystemActorDriver,
FileSystemManagerDriver,
} from "@actor-core/file-system";
export namespace Realtime {
const app = setup({
actors: { chatRoom },
basePath: "/realtime"
});
const fsState = new FileSystemGlobalState("/tmp");
const realtimeRouter = createRouter(app, {
topology: "standalone",
drivers: {
manager: new FileSystemManagerDriver(app, fsState),
actor: new FileSystemActorDriver(fsState),
}
});
export const route = realtimeRouter.router;
export const webSocketHandler = realtimeRouter.webSocketHandler;
}

View File

@@ -0,0 +1,165 @@
import { z } from "zod";
import { Hono } from "hono";
import { Resource } from "sst";
import { describeRoute } from "hono-openapi";
import { User } from "@nestri/core/user/index";
import { Examples } from "@nestri/core/examples";
import { Steam } from "@nestri/core/steam/index";
import { getCookie, setCookie } from "hono/cookie";
import { Client } from "@nestri/core/client/index";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
import { ErrorResponses, validator, Result, notPublic } from "./utils";
export namespace SteamApi {
export const route = new Hono()
.get("/",
describeRoute({
tags: ["Steam"],
summary: "List Steam accounts",
description: "List all Steam accounts belonging to this user",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Steam.Info.array().openapi({
description: "All linked Steam accounts",
example: [Examples.SteamAccount]
})
),
},
},
description: "Linked Steam accounts details"
},
400: ErrorResponses[400],
429: ErrorResponses[429],
}
}),
notPublic,
async (c) =>
c.json({
data: await Steam.list()
})
)
.get("/callback/:id",
validator(
"param",
z.object({
id: z.string().openapi({
description: "ID of the user to login",
example: Examples.User.id,
}),
}),
),
async (c) => {
const cookieID = getCookie(c, "user_id");
const userID = c.req.valid("param").id;
if (!cookieID || cookieID !== userID) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
"You should not be here"
);
}
const currentUser = await User.fromID(userID);
if (!currentUser) {
throw new VisibleError(
"not_found",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`User ${userID} not found`
)
}
const params = new URL(c.req.url).searchParams;
// Verify OpenID response and get steamID
const steamID = await Client.verifyOpenIDResponse(params);
// If verification failed, return error
if (!steamID) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
"Invalid OpenID authentication response"
);
}
const user = (await Client.getUserInfo([steamID]))[0];
if (!user) {
throw new VisibleError(
"internal",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
"Steam user data is missing"
);
}
const wasAdded = await Steam.create({ ...user, userID });
if (!wasAdded) {
// Update the owner of the Steam account
await Steam.updateOwner({ userID, steamID })
}
return c.html(
`
<script>
window.location.href = "about:blank";
window.close()
</script>
`
)
}
)
.get("/popup/:id",
describeRoute({
tags: ["Steam"],
summary: "Login to Steam",
description: "Login to Steam in a popup",
responses: {
400: ErrorResponses[400],
429: ErrorResponses[429],
}
}),
validator(
"param",
z.object({
id: z.string().openapi({
description: "ID of the user to login",
example: Examples.User.id,
}),
}),
),
async (c) => {
const userID = c.req.valid("param").id;
const user = await User.fromID(userID);
if (!user) {
throw new VisibleError(
"not_found",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`User ${userID} not found`
)
}
setCookie(c, "user_id", user.id);
const returnUrl = `${new URL(Resource.Urls.api).origin}/steam/callback/${userID}`
const params = new URLSearchParams({
'openid.ns': 'http://specs.openid.net/auth/2.0',
'openid.mode': 'checkid_setup',
'openid.return_to': returnUrl,
'openid.realm': new URL(returnUrl).origin,
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
'user_id': user.id
});
return c.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`, 302)
}
)
}

View File

@@ -0,0 +1,77 @@
import { Resource } from "sst";
import { subjects } from "../../subjects";
import { Actor } from "@nestri/core/actor";
import { type MiddlewareHandler } from "hono";
import { Steam } from "@nestri/core/steam/index";
import { createClient } from "@openauthjs/openauth/client";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
const client = createClient({
clientID: "api",
issuer: Resource.Auth.url,
});
export const notPublic: MiddlewareHandler = async (c, next) => {
const actor = Actor.use();
if (actor.type === "public")
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
"Missing authorization header",
);
return next();
};
export const auth: MiddlewareHandler = async (c, next) => {
const authHeader = c.req.header("authorization");
if (!authHeader) return Actor.provide("public", {}, next);
const match = authHeader.match(/^Bearer (.+)$/);
if (!match) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.INVALID_TOKEN,
"Invalid personal access token",
);
}
const bearerToken = match[1];
let result = await client.verify(subjects, bearerToken!);
if (result.err) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.INVALID_TOKEN,
"Invalid bearer token",
);
}
if (result.subject.type === "user") {
const steamID = c.req.header("x-nestri-steam");
if (!steamID) {
return Actor.provide(result.subject.type, result.subject.properties, next);
}
const userID = result.subject.properties.userID
return Actor.provide(
"steam",
{
steamID
},
async () => {
const steamAcc = await Steam.confirmOwnerShip(userID)
if (!steamAcc) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
`You don't have permission to access this resource.`
)
}
return Actor.provide(
"member",
{
steamID,
userID,
},
next)
});
}
return Actor.provide("public", {}, 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

@@ -0,0 +1,20 @@
import { ZodError, ZodSchema, z } from 'zod';
import type { Env, ValidationTargets, Context, TypedResponse, Input, MiddlewareHandler } from 'hono';
type Hook<T, E extends Env, P extends string, Target extends keyof ValidationTargets = keyof ValidationTargets, O = {}> = (result: ({
success: true;
data: T;
} | {
success: false;
error: ZodError;
data: T;
}) & {
target: Target;
}, c: Context<E, P>) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>;
type HasUndefined<T> = undefined extends T ? true : false;
declare const zValidator: <T extends ZodSchema<any, z.ZodTypeDef, any>, Target extends keyof ValidationTargets, E extends Env, P extends string, In = z.input<T>, Out = z.output<T>, I extends Input = {
in: HasUndefined<In> extends true ? { [K in Target]?: (In extends ValidationTargets[K] ? In : { [K2 in keyof In]?: ValidationTargets[K][K2] | undefined; }) | undefined; } : { [K_1 in Target]: In extends ValidationTargets[K_1] ? In : { [K2_1 in keyof In]: ValidationTargets[K_1][K2_1]; }; };
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 };

View File

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

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