feat(infra): Update infra and add support for teams to SST (#186)

## Description
- [x] Adds support for AWS SSO, which makes us (the team) able to use
SST and update the components independently
- [x] Splits the webpage into the landing page (Qwik), and Astro (the
console) in charge of playing. This allows us to pass in Environment
Variables to the console
- ~Migrates the docs from Nuxt to Nextjs, and connects them to SST. This
allows us to use Fumadocs _citation needed_ that's much more beautiful,
and supports OpenApi~
- Cloudflare pages with github integration is not working on our new CF
account. So we will have to push the pages deployment manually with
Github actions
- [x] Moves the current set up from my personal CF and AWS accounts to
dedicated Nestri accounts -

## Related Issues
<!-- List any related issues (e.g., "Closes #123", "Fixes #456") -->

## Type of Change

- [ ] Bug fix (non-breaking change)
- [x] New feature (non-breaking change)
- [ ] Breaking change (fix or feature that changes existing
functionality)
- [x] Documentation update
- [ ] Other (please describe):

## Checklist

- [x] I have updated relevant documentation
- [x] My code follows the project's coding style
- [x] My changes generate no new warnings/errors

## Notes for Reviewers
<!-- Point out areas you'd like reviewers to focus on, questions you
have, or decisions that need discussion -->
Please approve my PR 🥹


## Screenshots/Demo
<!-- If applicable, add screenshots or a GIF demo of your changes
(especially for UI changes) -->

## Additional Context
<!-- Add any other context about the pull request here -->
This commit is contained in:
Wanjohi
2025-02-27 18:52:05 +03:00
committed by GitHub
parent 237e016b2d
commit 457aac2258
138 changed files with 4218 additions and 2579 deletions

View File

@@ -0,0 +1,12 @@
import { Resource } from "sst";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/**/*.sql.ts",
out: "./migrations",
dialect: "postgresql",
verbose: true,
dbCredentials: {
url: `postgresql://${Resource.Database.user}:${Resource.Database.password}@${Resource.Database.host}/${Resource.Database.name}?sslmode=require`,
},
});

View File

@@ -1,30 +0,0 @@
// Docs: https://www.instantdb.com/docs/permissions
import type { InstantRules } from "@instantdb/core";
const rules = {
/**
* Welcome to Instant's permission system!
* Right now your rules are empty. To start filling them in, check out the docs:
* https://www.instantdb.com/docs/permissions
*
* Here's an example to give you a feel:
* posts: {
* allow: {
* view: "true",
* create: "isOwner",
* update: "isOwner",
* delete: "isOwner",
* },
* bind: ["isOwner", "auth.id != null && auth.id == data.ownerId"],
* },
*/
// $default: {
// allow: {
// $default: "isOwner"
// },
// bind: ["isOwner", "auth.id != null && auth.id == data.ownerID"],
// }
} satisfies InstantRules;
export default rules;

View File

@@ -1,123 +0,0 @@
import { i } from "@instantdb/core";
const _schema = i.schema({
entities: {
$users: i.entity({
email: i.string().unique().indexed(),
}),
// machines: i.entity({
// hostname: i.string(),
// fingerprint: i.string().unique().indexed(),
// deletedAt: i.date().optional().indexed(),
// createdAt: i.date()
// }),
tasks: i.entity({
type: i.string(),
lastStatus: i.string(),
healthStatus: i.string(),
startedAt: i.string(),
lastUpdated: i.date(),
stoppedAt: i.string().optional(),
taskID: i.string().unique().indexed()
}),
instances: i.entity({
hostname: i.string(),
lastActive: i.date().optional(),
createdAt: i.date()
}),
profiles: i.entity({
avatarUrl: i.string().optional(),
username: i.string().indexed(),
status: i.string().indexed(),
updatedAt: i.date().indexed(),
createdAt: i.date(),
discriminator: i.string().indexed()
}),
teams: i.entity({
name: i.string(),
slug: i.string().unique().indexed(),
deletedAt: i.date().optional(),//.indexed(),
updatedAt: i.date(),
createdAt: i.date(),
}),
// games: i.entity({
// name: i.string(),
// steamID: i.number().unique().indexed(),
// }),
sessions: i.entity({
startedAt: i.date(),
endedAt: i.date().optional().indexed(),
public: i.boolean().indexed(),
}),
subscriptions: i.entity({
checkoutID: i.string(),
canceledAt: i.date(),
})
},
links: {
UserSubscriptions: {
forward: { on: "subscriptions", has: "one", label: "owner" },
reverse: { on: "$users", has: "many", label: "subscriptions" }
},
UserProfiles: {
forward: { on: "profiles", has: "one", label: "owner" },
reverse: { on: "$users", has: "one", label: "profile" }
},
UserTasks: {
forward: { on: "tasks", has: "one", label: "owner" },
reverse: { on: "$users", has: "many", label: "tasks" }
},
TaskSessions: {
forward: { on: "tasks", has: "many", label: "sessions" },
reverse: { on: "sessions", has: "one", label: "task" }
},
UserSession: {
forward: { on: "sessions", has: "one", label: "owner" },
reverse: { on: "$users", has: "many", label: "sessions" }
},
TeamsOwned: {
forward: { on: "teams", has: "one", label: "owner" },
reverse: { on: "$users", has: "many", label: "teamsOwned" },
},
TeamsJoined: {
forward: { on: "teams", has: "many", label: "members" },
reverse: { on: "$users", has: "many", label: "teamsJoined" },
},
// UserMachines: {
// forward: { on: "machines", has: "one", label: "owner" },
// reverse: { on: "$users", has: "many", label: "machines" }
// },
// UserGames: {
// forward: { on: "games", has: "many", label: "owners" },
// reverse: { on: "$users", has: "many", label: "games" }
// },
// TeamInstances: {
// forward: { on: "instances", has: "many", label: "owners" },
// reverse: { on: "teams", has: "many", label: "instances" }
// },
// MachineSessions: {
// forward: { on: "machines", has: "many", label: "sessions" },
// reverse: { on: "sessions", has: "one", label: "machine" }
// },
// GamesMachines: {
// forward: { on: "machines", has: "many", label: "games" },
// reverse: { on: "games", has: "many", label: "machines" }
// },
// GameSessions: {
// forward: { on: "games", has: "many", label: "sessions" },
// reverse: { on: "sessions", has: "one", label: "game" }
// },
// UserSessions: {
// forward: { on: "sessions", has: "one", label: "owner" },
// reverse: { on: "$users", has: "many", label: "sessions" }
// }
}
});
// This helps Typescript display nicer intellisense
type _AppSchema = typeof _schema;
interface AppSchema extends _AppSchema { }
const schema: AppSchema = _schema;
export type { AppSchema };
export default schema;

View File

@@ -0,0 +1,37 @@
CREATE TABLE "member" (
"id" char(30) NOT NULL,
"team_id" char(30) NOT NULL,
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"time_seen" timestamp with time zone,
"email" varchar(255) NOT NULL,
CONSTRAINT "member_team_id_id_pk" PRIMARY KEY("team_id","id")
);
--> statement-breakpoint
CREATE TABLE "team" (
"id" char(30) PRIMARY KEY NOT NULL,
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"slug" varchar(255) NOT NULL,
"name" varchar(255) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" char(30) PRIMARY KEY NOT NULL,
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"avatar_url" text,
"email" varchar(255) NOT NULL,
"name" varchar(255) NOT NULL,
"discriminator" integer NOT NULL,
"polar_customer_id" varchar(255) NOT NULL,
CONSTRAINT "user_polar_customer_id_unique" UNIQUE("polar_customer_id")
);
--> statement-breakpoint
CREATE UNIQUE INDEX "member_email" ON "member" USING btree ("team_id","email");--> statement-breakpoint
CREATE INDEX "email_global" ON "member" USING btree ("email");--> statement-breakpoint
CREATE UNIQUE INDEX "slug" ON "team" USING btree ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "user_email" ON "user" USING btree ("email");

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ALTER COLUMN "polar_customer_id" DROP NOT NULL;

View File

@@ -0,0 +1,281 @@
{
"id": "08ba0262-ce0a-4d87-b4e2-0d17dc0ee28c",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.member": {
"name": "member",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "char(30)",
"primaryKey": false,
"notNull": true
},
"team_id": {
"name": "team_id",
"type": "char(30)",
"primaryKey": false,
"notNull": true
},
"time_created": {
"name": "time_created",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"time_seen": {
"name": "time_seen",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"member_email": {
"name": "member_email",
"columns": [
{
"expression": "team_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"email_global": {
"name": "email_global",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"member_team_id_id_pk": {
"name": "member_team_id_id_pk",
"columns": [
"team_id",
"id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.team": {
"name": "team",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "char(30)",
"primaryKey": true,
"notNull": true
},
"time_created": {
"name": "time_created",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"slug": {
"name": "slug",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "char(30)",
"primaryKey": true,
"notNull": true
},
"time_created": {
"name": "time_created",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"discriminator": {
"name": "discriminator",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"polar_customer_id": {
"name": "polar_customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"user_email": {
"name": "user_email",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_polar_customer_id_unique": {
"name": "user_polar_customer_id_unique",
"nullsNotDistinct": false,
"columns": [
"polar_customer_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,281 @@
{
"id": "c09359df-19fe-4246-9a41-43b3a429c12f",
"prevId": "08ba0262-ce0a-4d87-b4e2-0d17dc0ee28c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.member": {
"name": "member",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "char(30)",
"primaryKey": false,
"notNull": true
},
"team_id": {
"name": "team_id",
"type": "char(30)",
"primaryKey": false,
"notNull": true
},
"time_created": {
"name": "time_created",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"time_seen": {
"name": "time_seen",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"member_email": {
"name": "member_email",
"columns": [
{
"expression": "team_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"email_global": {
"name": "email_global",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"member_team_id_id_pk": {
"name": "member_team_id_id_pk",
"columns": [
"team_id",
"id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.team": {
"name": "team",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "char(30)",
"primaryKey": true,
"notNull": true
},
"time_created": {
"name": "time_created",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"slug": {
"name": "slug",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "char(30)",
"primaryKey": true,
"notNull": true
},
"time_created": {
"name": "time_created",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"discriminator": {
"name": "discriminator",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"polar_customer_id": {
"name": "polar_customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"user_email": {
"name": "user_email",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_polar_customer_id_unique": {
"name": "user_polar_customer_id_unique",
"nullsNotDistinct": false,
"columns": [
"polar_customer_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1740345380808,
"tag": "0000_wise_black_widow",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1740487217291,
"tag": "0001_flaky_tomorrow_man",
"breakpoints": true
}
]
}

View File

@@ -3,6 +3,14 @@
"version": "0.0.0",
"sideEffects": false,
"type": "module",
"scripts": {
"db": "sst shell drizzle-kit",
"db:push": "sst shell drizzle-kit push",
"db:migrate": "sst shell drizzle-kit migrate",
"db:generate": "sst shell drizzle-kit generate",
"db:connect": "sst shell ../scripts/src/psql.ts",
"db:move": "sst shell drizzle-kit generate && sst shell drizzle-kit migrate && sst shell drizzle-kit push"
},
"exports": {
"./*": "./src/*.ts"
},
@@ -10,6 +18,7 @@
"@tsconfig/node20": "^20.1.4",
"aws-iot-device-sdk-v2": "^1.21.1",
"aws4fetch": "^1.0.20",
"drizzle-kit": "^0.30.4",
"loops": "^3.4.1",
"mqtt": "^5.10.3",
"remeda": "^2.19.0",
@@ -19,6 +28,12 @@
"zod-openapi": "^4.2.2"
},
"dependencies": {
"@instantdb/admin": "^0.17.7"
"@aws-sdk/client-sesv2": "^3.753.0",
"@instantdb/admin": "^0.17.7",
"@neondatabase/serverless": "^0.10.4",
"@openauthjs/openevent": "^0.0.27",
"@polar-sh/sdk": "^0.26.1",
"drizzle-orm": "^0.39.3",
"ws": "^8.18.1"
}
}

View File

@@ -1,86 +1,92 @@
import { createContext } from "./context";
import { z } from "zod";
import { eq } from "./drizzle";
import { VisibleError } from "./error";
export interface UserActor {
type: "user";
properties: {
accessToken: string;
userID: string;
auth?:
| {
type: "personal";
token: string;
}
| {
type: "oauth";
clientID: string;
};
};
import { createContext } from "./context";
import { UserFlags, userTable } from "./user/user.sql";
import { useTransaction } from "./drizzle/transaction";
export const PublicActor = z.object({
type: z.literal("public"),
properties: z.object({}),
});
export type PublicActor = z.infer<typeof PublicActor>;
export const UserActor = z.object({
type: z.literal("user"),
properties: z.object({
userID: z.string(),
email: z.string().nonempty(),
}),
});
export type UserActor = z.infer<typeof UserActor>;
export const MemberActor = z.object({
type: z.literal("member"),
properties: z.object({
memberID: z.string(),
teamID: z.string(),
}),
});
export type MemberActor = z.infer<typeof MemberActor>;
export const SystemActor = z.object({
type: z.literal("system"),
properties: z.object({
teamID: z.string(),
}),
});
export type SystemActor = z.infer<typeof SystemActor>;
export const Actor = z.discriminatedUnion("type", [
MemberActor,
UserActor,
PublicActor,
SystemActor,
]);
export type Actor = z.infer<typeof Actor>;
const ActorContext = createContext<Actor>("actor");
export const useActor = ActorContext.use;
export const withActor = ActorContext.with;
export function useUserID() {
const actor = ActorContext.use();
if (actor.type === "user") return actor.properties.userID;
throw new VisibleError(
"unauthorized",
`You don't have permission to access this resource`,
);
}
export interface DeviceActor {
type: "device";
properties: {
teamSlug: string;
hostname: string;
auth?:
| {
type: "personal";
token: string;
}
| {
type: "oauth";
clientID: string;
};
};
}
export interface PublicActor {
type: "public";
properties: {};
}
type Actor = UserActor | PublicActor | DeviceActor;
export const ActorContext = createContext<Actor>();
export function useCurrentUser() {
const actor = ActorContext.use();
if (actor.type === "user") return {
id:actor.properties.userID,
token: actor.properties.accessToken,
};
throw new VisibleError(
"auth",
"unauthorized",
`You don't have permission to access this resource`,
);
}
export function useCurrentDevice() {
const actor = ActorContext.use();
if (actor.type === "device") return {
hostname:actor.properties.hostname,
teamSlug: actor.properties.teamSlug
};
throw new VisibleError(
"auth",
"unauthorized",
`You don't have permission to access this resource`,
);
}
export function useActor() {
try {
return ActorContext.use();
} catch {
return { type: "public", properties: {} } as PublicActor;
}
export function assertActor<T extends Actor["type"]>(type: T) {
const actor = useActor();
if (actor.type !== type) {
throw new Error(`Expected actor type ${type}, got ${actor.type}`);
}
export function assertActor<T extends Actor["type"]>(type: T) {
const actor = useActor();
if (actor.type !== type)
throw new VisibleError("auth", "actor.invalid", `Actor is not "${type}"`);
return actor as Extract<Actor, { type: T }>;
}
return actor as Extract<Actor, { type: T }>;
}
export function useTeam() {
const actor = useActor();
if ("teamID" in actor.properties) return actor.properties.teamID;
throw new Error(`Expected actor to have teamID`);
}
export async function assertUserFlag(flag: keyof UserFlags) {
return useTransaction((tx) =>
tx
.select({ flags: userTable.flags })
.from(userTable)
.where(eq(userTable.id, useUserID()))
.then((rows) => {
const flags = rows[0]?.flags;
if (!flags)
throw new VisibleError(
"user.flags",
"Actor does not have " + flag + " flag",
);
}),
);
}

View File

@@ -1,17 +1,17 @@
import { AsyncLocalStorage } from "node:async_hooks";
export function createContext<T>() {
export function createContext<T>(name: string) {
const storage = new AsyncLocalStorage<T>();
return {
use() {
const result = storage.getStore();
if (!result) {
throw new Error("No context available");
throw new Error("Context not provided: " + name);
}
return result;
},
with<R>(value: T, fn: () => R) {
return storage.run<R>(value, fn);
return storage.run<R, any[]>(value, fn);
},
};
}

View File

@@ -0,0 +1,22 @@
export * from "drizzle-orm";
import ws from 'ws';
import { Resource } from "sst";
import { drizzle as neonDrizzle, NeonDatabase } from "drizzle-orm/neon-serverless";
// import { drizzle } from 'drizzle-orm/postgres-js';
import { Pool, neonConfig } from "@neondatabase/serverless";
neonConfig.webSocketConstructor = ws;
const client = new Pool({ connectionString: `postgres://${Resource.Database.user}:${Resource.Database.password}@${Resource.Database.host}/${Resource.Database.name}?sslmode=require` })
export const db = neonDrizzle(client, {
logger:
process.env.DRIZZLE_LOG === "true"
? {
logQuery(query, params) {
console.log("query", query);
console.log("params", params);
},
}
: undefined,
});

View File

@@ -0,0 +1,65 @@
import { db } from ".";
import {
PgTransaction,
PgTransactionConfig
} from "drizzle-orm/pg-core";
import {
NeonQueryResultHKT
// NeonHttpQueryResultHKT
} from "drizzle-orm/neon-serverless";
import { ExtractTablesWithRelations } from "drizzle-orm";
import { createContext } from "../context";
export type Transaction = PgTransaction<
NeonQueryResultHKT,
Record<string, never>,
ExtractTablesWithRelations<Record<string, never>>
>;
type TxOrDb = Transaction | typeof db;
const TransactionContext = createContext<{
tx: Transaction;
effects: (() => void | Promise<void>)[];
}>("TransactionContext");
export async function useTransaction<T>(callback: (trx: TxOrDb) => Promise<T>) {
try {
const { tx } = TransactionContext.use();
return callback(tx);
} catch {
return callback(db);
}
}
export async function afterTx(effect: () => any | Promise<any>) {
try {
const { effects } = TransactionContext.use();
effects.push(effect);
} catch {
await effect();
}
}
export async function createTransaction<T>(
callback: (tx: Transaction) => Promise<T>,
isolationLevel?: PgTransactionConfig["isolationLevel"],
): Promise<T> {
try {
const { tx } = TransactionContext.use();
return callback(tx);
} catch {
const effects: (() => void | Promise<void>)[] = [];
const result = await db.transaction(
async (tx) => {
return TransactionContext.with({ tx, effects }, () => callback(tx));
},
{
isolationLevel: isolationLevel || "read committed",
},
);
await Promise.all(effects.map((x) => x()));
// await db.$client.end()
return result as T;
}
}

View File

@@ -0,0 +1,30 @@
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
export const ulid = (name: string) => char(name, { length: 26 + 4 });
export const id = {
get id() {
return ulid("id").primaryKey().notNull();
},
};
export const teamID = {
get id() {
return ulid("id").notNull();
},
get teamID() {
return ulid("team_id").notNull();
},
};
export const utc = (name: string) =>
rawTs(name, {
withTimezone: true,
// mode: "date"
});
export const timestamps = {
timeCreated: utc("time_created").notNull().defaultNow(),
timeUpdated: utc("time_updated").notNull().defaultNow(),
timeDeleted: utc("time_deleted"),
};

View File

@@ -1,45 +1,36 @@
import { LoopsClient } from "loops";
import { Resource } from "sst/resource"
import { Resource } from "sst";
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
export namespace Email {
export const Client = () => new LoopsClient(Resource.LoopsApiKey.value);
export const Client = new SESv2Client({});
export async function send(
to: string,
body: string,
) {
try {
await Client().sendTransactionalEmail(
{
transactionalId: "cm58pdf8d03upb5ecirnmvrfb",
email: to,
dataVariables: {
logincode: body
}
}
);
} catch (error) {
console.log("error sending email", error)
}
}
export async function sendWelcome(
to: string,
name: string,
) {
try {
await Client().sendTransactionalEmail(
{
transactionalId: "cm61jrbbx02twlstfwfcywt5u",
email: to,
dataVariables: {
name
}
}
);
} catch (error) {
console.log("error sending email", error)
}
}
export async function send(
from: string,
to: string,
subject: string,
body: string,
) {
from = from + "@" + Resource.Mail.sender;
console.log("sending email", subject, from, to);
await Client.send(
new SendEmailCommand({
Destination: {
ToAddresses: [to],
},
Content: {
Simple: {
Body: {
Text: {
Data: body,
},
},
Subject: {
Data: subject,
},
},
},
FromEmailAddress: `Nestri <${from}>`,
}),
);
}
}

View File

@@ -1,6 +1,5 @@
export class VisibleError extends Error {
constructor(
public kind: "input" | "auth",
public code: string,
public message: string,
) {

View File

@@ -0,0 +1,23 @@
import { useActor } from "./actor";
import { event as sstEvent } from "sst/event";
import { ZodValidator } from "sst/event/validator";
export const createEvent = sstEvent.builder({
validator: ZodValidator,
metadata() {
return {
actor: useActor(),
};
},
});
import { openevent } from "@openauthjs/openevent/event";
export { publish } from "@openauthjs/openevent/publisher/drizzle";
export const event = openevent({
metadata() {
return {
actor: useActor(),
};
},
});

View File

@@ -1,75 +1,29 @@
import { teamID } from "./drizzle/types";
import { prefixes } from "./utils";
export module Examples {
export const Id = (prefix: keyof typeof prefixes) =>
`${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
export const User = {
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
id: Id("user"),
name: "John Doe",
email: "john@example.com",
discriminator: 47,
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
};
export const Task = {
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
taskID: "b8302fca2d224d91ab342a2e4ab926d3",
type: "AWS" as const, //or "on-premises",
lastStatus: "RUNNING" as const,
healthStatus: "UNKNOWN" as const,
startedAt: '2025-01-09T01:56:23.902Z',
lastUpdated: '2025-01-09T01:56:23.902Z',
stoppedAt: '2025-01-09T04:46:23.902Z'
}
export const Profile = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
username: "janedoe47",
status: "active" as const,
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
discriminator: 12, //it needs to be two digits
createdAt: '2025-01-04T11:56:23.902Z',
updatedAt: '2025-01-09T01:56:23.902Z'
}
export const Subscription = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
checkoutID: "0bfcb712-df43-4454-81a8-fbee66eddca4",
// productID: "0bfcb712-df43-4454-81a8-fbee66eddca4",
// quantity: 1,
// frequency: "monthly" as const,
// next: '2025-01-09T01:56:23.902Z',
canceledAt: '2025-02-09T01:56:23.902Z'
}
export const Team = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
// owner: true,
name: "Jane Doe's Games",
slug: "jane-does-games",
createdAt: '2025-01-04T11:56:23.902Z',
updatedAt: '2025-01-09T01:56:23.902Z'
id: Id("team"),
name: "John Does' Team",
slug: "john_doe",
}
export const Member = {
id: Id("member"),
email: "john@example.com",
teamID: Id("team"),
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
}
export const Machine = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
hostname: "DESKTOP-EUO8VSF",
fingerprint: "fc27f428f9ca47d4b41b70889ae0c62090",
createdAt: '2025-01-04T11:56:23.902Z',
deletedAt: '2025-01-09T01:56:23.902Z'
}
export const Instance = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
hostname: "a955e059f05d",
createdAt: '2025-01-04T11:56:23.902Z',
lastActive: '2025-01-09T01:56:23.902Z'
}
export const Game = {
id: '0bfcb712-df13-4454-81a8-fbee66eddca4',
name: "Control Ultimate Edition",
steamID: 870780,
}
export const Session = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
public: true,
startedAt: '2025-01-04T11:56:23.902Z',
endedAt: '2025-01-04T12:36:23.902Z'
}
}

View File

@@ -0,0 +1,133 @@
import { z } from "zod";
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { useTeam } from "../actor";
import { Common } from "../common";
import { createID, fn } from "../utils";
import { createEvent } from "../event";
import { Examples } from "../examples";
import { memberTable } from "./member.sql";
import { and, eq, sql, asc, isNull } from "../drizzle";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
export module Member {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Member.id,
}),
timeSeen: z.date().or(z.null()).openapi({
description: "The last time this team member was active",
example: Examples.Member.timeSeen
}),
teamID: z.string().openapi({
description: "The unique id of the team this member is on",
example: Examples.Member.teamID
}),
email: z.string().openapi({
description: "The email of this team member",
example: Examples.Member.email
})
})
.openapi({
ref: "Member",
description: "Represents a team member on Nestri",
example: Examples.Member,
});
export type Info = z.infer<typeof Info>;
export const Events = {
Created: createEvent(
"member.created",
z.object({
memberID: Info.shape.id,
}),
),
Updated: createEvent(
"member.updated",
z.object({
memberID: Info.shape.id,
}),
),
};
export const create = fn(
Info.pick({ email: true, id: true })
.partial({
id: true,
})
.extend({
first: z.boolean().optional(),
}),
(input) =>
createTransaction(async (tx) => {
const id = input.id ?? createID("member");
await tx.insert(memberTable).values({
id,
email: input.email,
teamID: useTeam(),
timeSeen: input.first ? sql`CURRENT_TIMESTAMP()` : null,
}).onConflictDoUpdate({
target: memberTable.id,
set: {
timeDeleted: null,
}
})
await afterTx(() =>
async () => bus.publish(Resource.Bus, Events.Created, { memberID: id }),
);
return id;
}),
);
export const remove = fn(Info.shape.id, (input) =>
useTransaction(async (tx) => {
await tx
.update(memberTable)
.set({
timeDeleted: sql`CURRENT_TIMESTAMP()`,
})
.where(and(eq(memberTable.id, input), eq(memberTable.teamID, useTeam())))
.execute();
return input;
}),
);
export const fromEmail = fn(z.string(), async (email) =>
useTransaction(async (tx) =>
tx
.select()
.from(memberTable)
.where(and(eq(memberTable.email, email), isNull(memberTable.timeDeleted)))
.orderBy(asc(memberTable.timeCreated))
.then((rows) => rows.map(serialize))
.then((rows) => rows.at(0))
),
)
export const fromID = fn(z.string(), async (id) =>
useTransaction(async (tx) =>
tx
.select()
.from(memberTable)
.where(and(eq(memberTable.id, id), isNull(memberTable.timeDeleted)))
.orderBy(asc(memberTable.timeCreated))
.then((rows) => rows.map(serialize))
.then((rows) => rows.at(0))
),
)
export function serialize(
input: typeof memberTable.$inferSelect,
): z.infer<typeof Info> {
return {
id: input.id,
email: input.email,
teamID: input.teamID,
timeSeen: input.timeSeen
};
}
}

View File

@@ -0,0 +1,18 @@
import { teamIndexes } from "../team/team.sql";
import { timestamps, utc, teamID } from "../drizzle/types";
import { index, pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core";
export const memberTable = pgTable(
"member",
{
...teamID,
...timestamps,
timeSeen: utc("time_seen"),
email: varchar("email", { length: 255 }).notNull(),
},
(table) => [
...teamIndexes(table),
uniqueIndex("member_email").on(table.teamID, table.email),
index("email_global").on(table.email),
],
);

View File

@@ -0,0 +1,8 @@
import { Resource } from "sst";
import { Polar as PolarSdk } from "@polar-sh/sdk";
const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" });
export module Polar {
export const client = polar;
}

View File

@@ -1,164 +1,152 @@
import { z } from "zod";
import databaseClient from "../database"
import { fn } from "../utils";
import { groupBy, map, pipe, values } from "remeda"
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { Common } from "../common";
import { createID, fn } from "../utils";
import { VisibleError } from "../error";
import { Examples } from "../examples";
import { useCurrentUser } from "../actor";
import { id as createID } from "@instantdb/admin";
import { teamTable } from "./team.sql";
import { createEvent } from "../event";
import { assertActor } from "../actor";
import { and, eq, sql } from "../drizzle";
import { memberTable } from "../member/member.sql";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
export namespace Teams {
export module Team {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Team.id,
}),
name: z.string().openapi({
description: "Name of the team",
example: Examples.Team.name,
}),
createdAt: z.string().or(z.number()).openapi({
description: "The time when this team was first created",
example: Examples.Team.createdAt,
}),
updatedAt: z.string().or(z.number()).openapi({
description: "The time when this team was last edited",
example: Examples.Team.updatedAt,
}),
// owner: z.boolean().openapi({
// description: "Whether this team is owned by this user",
// example: Examples.Team.owner,
// }),
slug: z.string().openapi({
description: "This is the unique name identifier for the team",
description: "The unique and url-friendly slug of this team",
example: Examples.Team.slug
}),
name: z.string().openapi({
description: "The name of this team",
example: Examples.Team.name
})
})
.openapi({
ref: "Team",
description: "A group of users sharing the same machines for gaming.",
description: "Represents a team on Nestri",
example: Examples.Team,
});
export type Info = z.infer<typeof Info>;
export const list = async () => {
const db = databaseClient()
const user = useCurrentUser()
export const Events = {
Created: createEvent(
"team.created",
z.object({
teamID: z.string().nonempty(),
}),
),
};
const query = {
teams: {
$: {
where: {
members: user.id,
deletedAt: { $isNull: true }
}
},
}
export class WorkspaceExistsError extends VisibleError {
constructor(slug: string) {
super(
"team.slug_exists",
`there is already a workspace named "${slug}"`,
);
}
const res = await db.query(query)
const teams = res.teams
if (!teams || teams.length === 0) {
return null
}
const result = pipe(
teams,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
name: group[0].name,
createdAt: group[0].createdAt,
updatedAt: group[0].updatedAt,
slug: group[0].slug,
//@ts-expect-error
owner: group[0].owner === user.id
}))
)
return result
}
export const create = fn(
Info.pick({ slug: true, id: true, name: true }).partial({
id: true,
}), (input) => {
createTransaction(async (tx) => {
const id = input.id ?? createID("team");
const result = await tx.insert(teamTable).values({
id,
slug: input.slug,
name: input.name
})
.onConflictDoNothing()
.returning({ insertedID: teamTable.id })
export const fromSlug = fn(z.string(), async (slug) => {
const db = databaseClient()
if (result.length === 0) throw new WorkspaceExistsError(input.slug);
const query = {
teams: {
$: {
where: {
slug,
deletedAt: { $isNull: true }
}
},
}
}
await afterTx(() =>
bus.publish(Resource.Bus, Events.Created, {
teamID: id,
}),
);
return id;
})
})
const res = await db.query(query)
export const remove = fn(Info.shape.id, (input) =>
useTransaction(async (tx) => {
const account = assertActor("user");
const row = await tx
.select({
teamID: memberTable.teamID,
})
.from(memberTable)
.where(
and(
eq(memberTable.teamID, input),
eq(memberTable.email, account.properties.email),
),
)
.execute()
.then((rows) => rows.at(0));
if (!row) return;
await tx
.update(teamTable)
.set({
timeDeleted: sql`now()`,
})
.where(eq(teamTable.id, row.teamID));
}),
);
const teams = res.teams
if (!teams || teams.length === 0) {
return null
}
export const list = fn(z.void(), () =>
useTransaction((tx) =>
tx
.select()
.from(teamTable)
.execute()
.then((rows) => rows.map(serialize)),
),
);
const result = pipe(
teams,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
name: group[0].name,
slug: group[0].slug,
createdAt: group[0].createdAt,
updatedAt: group[0].updatedAt,
// owner: group[0].owner === user.id
}))
)
export const fromID = fn(z.string().min(1), async (id) =>
useTransaction(async (tx) => {
return tx
.select()
.from(teamTable)
.where(eq(teamTable.id, id))
.execute()
.then((rows) => rows.map(serialize))
.then((rows) => rows.at(0));
}),
);
return result[0]
})
export const fromSlug = fn(z.string().min(1), async (input) =>
useTransaction(async (tx) => {
return tx
.select()
.from(teamTable)
.where(eq(teamTable.slug, input))
.execute()
.then((rows) => rows.map(serialize))
.then((rows) => rows.at(0));
}),
);
export const create = fn(Info.pick({ name: true, slug: true }), async (input) => {
const id = createID()
const db = databaseClient()
const user = useCurrentUser()
const now = new Date().toISOString()
await db.transact(db.tx.teams[id]!.update({
export function serialize(
input: typeof teamTable.$inferSelect,
): z.infer<typeof Info> {
return {
id: input.id,
name: input.name,
slug: input.slug,
createdAt: now,
updatedAt: now,
}).link({ owner: user.id, members: user.id }))
return id
})
export const remove = fn(z.string(), async (id) => {
const db = databaseClient()
const now = new Date().toISOString()
await db.transact(db.tx.teams[id]!.update({
deletedAt: now
}))
return "ok"
})
export const invite = fn(z.object({email:z.string(), id: z.string()}), async (input) => {
//TODO:
// const db = databaseClient()
// const now = new Date().toISOString()
// await db.transact(db.tx.teams[id]!.update({
// deletedAt: now
// }))
return "ok"
})
};
}
}

View File

@@ -0,0 +1,27 @@
import {} from "drizzle-orm/postgres-js";
import { timestamps, id } from "../drizzle/types";
import {
pgTable,
primaryKey,
uniqueIndex,
varchar,
} from "drizzle-orm/pg-core";
export const teamTable = pgTable(
"team",
{
...id,
...timestamps,
slug: varchar("slug", { length: 255 }).notNull(),
name: varchar("name", { length: 255 }).notNull(),
},
(table) => [uniqueIndex("slug").on(table.slug)],
);
export function teamIndexes(table: any) {
return [
primaryKey({
columns: [table.teamID, table.id],
}),
];
}

View File

@@ -1,37 +1,219 @@
import { z } from "zod";
import databaseClient from "../database"
import { fn } from "../utils";
import { bus } from "sst/aws/bus";
import { Common } from "../common";
import { createID, fn } from "../utils";
import { userTable } from "./user.sql";
import { createEvent } from "../event";
import { Examples } from "../examples";
import { Resource } from "sst/resource";
import { teamTable } from "../team/team.sql";
import { assertActor, withActor } from "../actor";
import { memberTable } from "../member/member.sql";
import { and, eq, isNull, asc, getTableColumns, sql } from "../drizzle";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
import { Team } from "../team";
export module User {
const MAX_ATTEMPTS = 50;
export module Users {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.User.id,
}),
email: z.string().nullable().openapi({
description: "Email address of the user.",
name: z.string().openapi({
description: "The user's unique username",
example: Examples.User.name,
}),
polarCustomerID: z.string().or(z.null()).openapi({
description: "The polar customer id for this user",
example: Examples.User.polarCustomerID,
}),
email: z.string().openapi({
description: "The email address of this user",
example: Examples.User.email,
}),
avatarUrl: z.string().or(z.null()).openapi({
description: "The url to the profile picture.",
example: Examples.User.name,
}),
discriminator: z.string().or(z.number()).openapi({
description: "The (number) discriminator for this user",
example: Examples.User.discriminator,
}),
})
.openapi({
ref: "User",
description: "A Nestri console user.",
description: "Represents a user on Nestri",
example: Examples.User,
});
export const fromEmail = fn(z.string(), async (email) => {
const db = databaseClient()
const res = await db.auth.getUser({ email })
return res
export type Info = z.infer<typeof Info>;
export const Events = {
Created: createEvent(
"user.created",
z.object({
userID: Info.shape.id,
}),
),
Updated: createEvent(
"user.updated",
z.object({
userID: Info.shape.id,
}),
),
};
export const sanitizeUsername = (username: string): string => {
// Remove spaces and numbers
return username.replace(/[\s0-9]/g, '');
};
export const generateDiscriminator = (): string => {
return Math.floor(Math.random() * 100).toString().padStart(2, '0');
};
export const isValidDiscriminator = (discriminator: string): boolean => {
return /^\d{2}$/.test(discriminator);
};
export const findAvailableDiscriminator = fn(z.string(), async (input) => {
const username = sanitizeUsername(input);
for (let i = 0; i < MAX_ATTEMPTS; i++) {
const discriminator = generateDiscriminator();
const users = await useTransaction(async (tx) =>
tx
.select()
.from(userTable)
.where(and(eq(userTable.name, username), eq(userTable.discriminator, Number(discriminator))))
)
if (users.length === 0) {
return discriminator;
}
}
return null;
})
export const create = fn(z.string(), async (email) => {
const db = databaseClient()
const token = await db.auth.createToken(email)
export const create = fn(Info.omit({ polarCustomerID: true, discriminator: true }).partial({ avatarUrl: true, id: true }), async (input) => {
const userID = createID("user")
return token
//FIXME: Do this much later, as Polar.sh has so many inconsistencies for fuck's sake
// const customer = await Polar.client.customers.create({
// email: input.email,
// metadata: {
// userID,
// },
// });
const name = sanitizeUsername(input.name);
// Generate a random available discriminator
const discriminator = await findAvailableDiscriminator(name);
if (!discriminator) {
console.error("No available discriminators for this username ")
return null
}
createTransaction(async (tx) => {
const id = input.id ?? userID;
await tx.insert(userTable).values({
id,
name: input.name,
avatarUrl: input.avatarUrl,
email: input.email,
discriminator: Number(discriminator),
})
await afterTx(() =>
withActor({
type: "user",
properties: {
userID: id,
email: input.email
},
},
async () => bus.publish(Resource.Bus, Events.Created, { userID: id }),
)
);
})
return userID;
})
export const fromEmail = fn(z.string(), async (email) =>
useTransaction(async (tx) =>
tx
.select()
.from(userTable)
.where(and(eq(userTable.email, email), isNull(userTable.timeDeleted)))
.orderBy(asc(userTable.timeCreated))
.then((rows) => rows.map(serialize))
.then((rows) => rows.at(0))
),
)
export const fromID = fn(z.string(), async (id) =>
useTransaction(async (tx) =>
tx
.select()
.from(userTable)
.where(and(eq(userTable.id, id), isNull(userTable.timeDeleted)))
.orderBy(asc(userTable.timeCreated))
.then((rows) => rows.map(serialize))
.then((rows) => rows.at(0))
),
)
export function serialize(
input: typeof userTable.$inferSelect,
): z.infer<typeof Info> {
return {
id: input.id,
name: input.name,
email: input.email,
avatarUrl: input.avatarUrl,
discriminator: input.discriminator,
polarCustomerID: input.polarCustomerID,
};
}
export const remove = fn(Info.shape.id, (input) =>
useTransaction(async (tx) => {
await tx
.update(userTable)
.set({
timeDeleted: sql`CURRENT_TIMESTAMP()`,
})
.where(and(eq(userTable.id, input)))
.execute();
return input;
}),
);
export function teams() {
const actor = assertActor("user");
return useTransaction((tx) =>
tx
.select(getTableColumns(teamTable))
.from(teamTable)
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
.where(
and(
eq(memberTable.email, actor.properties.email),
isNull(memberTable.timeDeleted),
isNull(teamTable.timeDeleted),
),
)
.execute()
.then((rows) => rows.map(Team.serialize))
);
}
}

View File

@@ -0,0 +1,27 @@
import { z } from "zod";
import { id, timestamps } from "../drizzle/types";
import { integer, pgTable, text, uniqueIndex, varchar,json } from "drizzle-orm/pg-core";
// Whether this user is part of the Nestri Team, comes with privileges
export const UserFlags = z.object({
team: z.boolean().optional(),
});
export type UserFlags = z.infer<typeof UserFlags>;
export const userTable = pgTable(
"user",
{
...id,
...timestamps,
avatarUrl: text("avatar_url"),
email: varchar("email", { length: 255 }).notNull(),
name: varchar("name", { length: 255 }).notNull(),
discriminator: integer("discriminator").notNull(),
polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(),
flags: json("flags").$type<UserFlags>().default({}),
},
(user) => [
uniqueIndex("user_email").on(user.email),
],
);

View File

@@ -0,0 +1,11 @@
import { ulid } from "ulid";
export const prefixes = {
user: "usr",
team: "tea",
member: "mbr"
} as const;
export function createID(prefix: keyof typeof prefixes): string {
return [prefixes[prefix], ulid()].join("_");
}

View File

@@ -1 +1,2 @@
export * from "./fn"
export * from "./fn"
export * from "./id"

View File

@@ -0,0 +1,86 @@
import { createContext } from "../src/context";
import { VisibleError } from "./error";
export interface UserActor {
type: "user";
properties: {
accessToken: string;
userID: string;
auth?:
| {
type: "personal";
token: string;
}
| {
type: "oauth";
clientID: string;
};
};
}
export interface DeviceActor {
type: "device";
properties: {
teamSlug: string;
hostname: string;
auth?:
| {
type: "personal";
token: string;
}
| {
type: "oauth";
clientID: string;
};
};
}
export interface PublicActor {
type: "public";
properties: {};
}
type Actor = UserActor | PublicActor | DeviceActor;
export const ActorContext = createContext<Actor>();
export function useCurrentUser() {
const actor = ActorContext.use();
if (actor.type === "user") return {
id:actor.properties.userID,
token: actor.properties.accessToken,
};
throw new VisibleError(
"auth",
"unauthorized",
`You don't have permission to access this resource`,
);
}
export function useCurrentDevice() {
const actor = ActorContext.use();
if (actor.type === "device") return {
hostname:actor.properties.hostname,
teamSlug: actor.properties.teamSlug
};
throw new VisibleError(
"auth",
"unauthorized",
`You don't have permission to access this resource`,
);
}
export function useActor() {
try {
return ActorContext.use();
} catch {
return { type: "public", properties: {} } as PublicActor;
}
}
export function assertActor<T extends Actor["type"]>(type: T) {
const actor = useActor();
if (actor.type !== type)
throw new VisibleError("auth", "actor.invalid", `Actor is not "${type}"`);
return actor as Extract<Actor, { type: T }>;
}

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
import "zod-openapi/extend";
export module Common {
export const IdDescription = `Unique object identifier.
The format and length of IDs may change over time.`;
}

View File

@@ -0,0 +1,45 @@
import { LoopsClient } from "loops";
import { Resource } from "sst/resource"
export namespace Email {
export const Client = () => new LoopsClient(Resource.LoopsApiKey.value);
export async function send(
to: string,
body: string,
) {
try {
await Client().sendTransactionalEmail(
{
transactionalId: "cm58pdf8d03upb5ecirnmvrfb",
email: to,
dataVariables: {
logincode: body
}
}
);
} catch (error) {
console.log("error sending email", error)
}
}
export async function sendWelcome(
to: string,
name: string,
) {
try {
await Client().sendTransactionalEmail(
{
transactionalId: "cm61jrbbx02twlstfwfcywt5u",
email: to,
dataVariables: {
name
}
}
);
} catch (error) {
console.log("error sending email", error)
}
}
}

View File

@@ -0,0 +1,9 @@
export class VisibleError extends Error {
constructor(
public kind: "input" | "auth",
public code: string,
public message: string,
) {
super(message);
}
}

View File

@@ -0,0 +1,75 @@
export module Examples {
export const User = {
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
email: "john@example.com",
};
export const Task = {
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
taskID: "b8302fca2d224d91ab342a2e4ab926d3",
type: "AWS" as const, //or "on-premises",
lastStatus: "RUNNING" as const,
healthStatus: "UNKNOWN" as const,
startedAt: '2025-01-09T01:56:23.902Z',
lastUpdated: '2025-01-09T01:56:23.902Z',
stoppedAt: '2025-01-09T04:46:23.902Z'
}
export const Profile = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
username: "janedoe47",
status: "active" as const,
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
discriminator: 12, //it needs to be two digits
createdAt: '2025-01-04T11:56:23.902Z',
updatedAt: '2025-01-09T01:56:23.902Z'
}
export const Subscription = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
checkoutID: "0bfcb712-df43-4454-81a8-fbee66eddca4",
// productID: "0bfcb712-df43-4454-81a8-fbee66eddca4",
// quantity: 1,
// frequency: "monthly" as const,
// next: '2025-01-09T01:56:23.902Z',
canceledAt: '2025-02-09T01:56:23.902Z'
}
export const Team = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
// owner: true,
name: "Jane Doe's Games",
slug: "jane-does-games",
createdAt: '2025-01-04T11:56:23.902Z',
updatedAt: '2025-01-09T01:56:23.902Z'
}
export const Machine = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
hostname: "DESKTOP-EUO8VSF",
fingerprint: "fc27f428f9ca47d4b41b70889ae0c62090",
createdAt: '2025-01-04T11:56:23.902Z',
deletedAt: '2025-01-09T01:56:23.902Z'
}
export const Instance = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
hostname: "a955e059f05d",
createdAt: '2025-01-04T11:56:23.902Z',
lastActive: '2025-01-09T01:56:23.902Z'
}
export const Game = {
id: '0bfcb712-df13-4454-81a8-fbee66eddca4',
name: "Control Ultimate Edition",
steamID: 870780,
}
export const Session = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
public: true,
startedAt: '2025-01-04T11:56:23.902Z',
endedAt: '2025-01-04T12:36:23.902Z'
}
}

View File

@@ -0,0 +1,164 @@
import { z } from "zod";
import databaseClient from "../database"
import { fn } from "../utils";
import { groupBy, map, pipe, values } from "remeda"
import { Common } from "../common";
import { Examples } from "../examples";
import { useCurrentUser } from "../actor";
import { id as createID } from "@instantdb/admin";
export namespace Teams {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Team.id,
}),
name: z.string().openapi({
description: "Name of the team",
example: Examples.Team.name,
}),
createdAt: z.string().or(z.number()).openapi({
description: "The time when this team was first created",
example: Examples.Team.createdAt,
}),
updatedAt: z.string().or(z.number()).openapi({
description: "The time when this team was last edited",
example: Examples.Team.updatedAt,
}),
// owner: z.boolean().openapi({
// description: "Whether this team is owned by this user",
// example: Examples.Team.owner,
// }),
slug: z.string().openapi({
description: "This is the unique name identifier for the team",
example: Examples.Team.slug
})
})
.openapi({
ref: "Team",
description: "A group of users sharing the same machines for gaming.",
example: Examples.Team,
});
export type Info = z.infer<typeof Info>;
export const list = async () => {
const db = databaseClient()
const user = useCurrentUser()
const query = {
teams: {
$: {
where: {
members: user.id,
deletedAt: { $isNull: true }
}
},
}
}
const res = await db.query(query)
const teams = res.teams
if (!teams || teams.length === 0) {
return null
}
const result = pipe(
teams,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
name: group[0].name,
createdAt: group[0].createdAt,
updatedAt: group[0].updatedAt,
slug: group[0].slug,
//@ts-expect-error
owner: group[0].owner === user.id
}))
)
return result
}
export const fromSlug = fn(z.string(), async (slug) => {
const db = databaseClient()
const query = {
teams: {
$: {
where: {
slug,
deletedAt: { $isNull: true }
}
},
}
}
const res = await db.query(query)
const teams = res.teams
if (!teams || teams.length === 0) {
return null
}
const result = pipe(
teams,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
name: group[0].name,
slug: group[0].slug,
createdAt: group[0].createdAt,
updatedAt: group[0].updatedAt,
// owner: group[0].owner === user.id
}))
)
return result[0]
})
export const create = fn(Info.pick({ name: true, slug: true }), async (input) => {
const id = createID()
const db = databaseClient()
const user = useCurrentUser()
const now = new Date().toISOString()
await db.transact(db.tx.teams[id]!.update({
name: input.name,
slug: input.slug,
createdAt: now,
updatedAt: now,
}).link({ owner: user.id, members: user.id }))
return id
})
export const remove = fn(z.string(), async (id) => {
const db = databaseClient()
const now = new Date().toISOString()
await db.transact(db.tx.teams[id]!.update({
deletedAt: now
}))
return "ok"
})
export const invite = fn(z.object({email:z.string(), id: z.string()}), async (input) => {
//TODO:
// const db = databaseClient()
// const now = new Date().toISOString()
// await db.transact(db.tx.teams[id]!.update({
// deletedAt: now
// }))
return "ok"
})
}

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import databaseClient from "../database"
import { fn } from "../utils";
import { Common } from "../common";
import { Examples } from "../examples";
export module Users {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.User.id,
}),
email: z.string().nullable().openapi({
description: "Email address of the user.",
example: Examples.User.email,
}),
})
.openapi({
ref: "User",
description: "A Nestri console user.",
example: Examples.User,
});
export const fromEmail = fn(z.string(), async (email) => {
const db = databaseClient()
const res = await db.auth.getUser({ email })
return res
})
export const create = fn(z.string(), async (email) => {
const db = databaseClient()
const token = await db.auth.createToken(email)
return token
})
}

View File

@@ -0,0 +1,27 @@
import { ZodSchema, z } from "zod";
export function fn<
Arg1 extends ZodSchema,
Callback extends (arg1: z.output<Arg1>) => any,
>(arg1: Arg1, cb: Callback) {
const result = function (input: z.input<typeof arg1>): ReturnType<Callback> {
const parsed = arg1.parse(input);
return cb.apply(cb, [parsed as any]);
};
result.schema = arg1;
return result;
}
export function doubleFn<
Arg1 extends ZodSchema,
Arg2 extends ZodSchema,
Callback extends (arg1: z.output<Arg1>, arg2: z.output<Arg2>) => any,
>(arg1: Arg1, arg2: Arg2, cb: Callback) {
const result = function (input: z.input<typeof arg1>, input2: z.input<typeof arg2>): ReturnType<Callback> {
const parsed = arg1.parse(input);
const parsed2 = arg2.parse(input2);
return cb.apply(cb, [parsed as any, parsed2 as any]);
};
result.schema = arg1;
return result;
}

View File

@@ -0,0 +1,9 @@
import { ulid } from "ulid";
export const prefixes = {
user: "usr",
} as const;
export function createID(prefix: keyof typeof prefixes): string {
return [prefixes[prefix], ulid()].join("_");
}

View File

@@ -0,0 +1,2 @@
export * from "./fn"
export * from "./id"

View File

@@ -14,6 +14,7 @@
"typescript": "^5.0.0"
},
"dependencies": {
"@openauthjs/openauth": "^0.3.9",
"hono": "^4.6.15",
"hono-openapi": "^0.3.1",
"partysocket": "1.0.3"

View File

@@ -1,121 +0,0 @@
import type { Context } from "hono"
import type { Adapter } from "@openauthjs/openauth/adapter/adapter"
import { generateUnbiasedDigits, timingSafeCompare } from "@openauthjs/openauth/random"
export type ApiAdapterState =
| {
type: "start"
}
| {
type: "code"
resend?: boolean
code: string
claims: Record<string, string>
}
export type ApiAdapterError =
| {
type: "invalid_code"
}
| {
type: "invalid_claim"
key: string
value: string
}
export function ApiAdapter<
Claims extends Record<string, string> = Record<string, string>,
>(config: {
length?: number
request: (
req: Request,
state: ApiAdapterState,
body?: Claims,
error?: ApiAdapterError,
) => Promise<Response>
sendCode: (claims: Claims, code: string) => Promise<void | ApiAdapterError>
}) {
const length = config.length || 6
function generate() {
return generateUnbiasedDigits(length)
}
return {
type: "api", // this is a miscellaneous name, for lack of a better one
init(routes, ctx) {
async function transition(
c: Context,
next: ApiAdapterState,
claims?: Claims,
err?: ApiAdapterError,
) {
await ctx.set<ApiAdapterState>(c, "adapter", 60 * 60 * 24, next)
const resp = ctx.forward(
c,
await config.request(c.req.raw, next, claims, err),
)
return resp
}
routes.get("/authorize", async (c) => {
const resp = await transition(c, {
type: "start",
})
return resp
})
routes.post("/authorize", async (c) => {
const code = generate()
const body = await c.req.json()
const state = await ctx.get<ApiAdapterState>(c, "adapter")
const action = body.action
if (action === "request" || action === "resend") {
const claims = body.claims as Claims
delete body.action
const err = await config.sendCode(claims, code)
if (err) return transition(c, { type: "start" }, claims, err)
return transition(
c,
{
type: "code",
resend: action === "resend",
claims,
code,
},
claims,
)
}
if (
body.action === "verify" &&
state.type === "code"
) {
const body = await c.req.json()
const compare = body.code
if (
!state.code ||
!compare ||
!timingSafeCompare(state.code, compare)
) {
return transition(
c,
{
...state,
resend: false,
},
body.claims,
{ type: "invalid_code" },
)
}
await ctx.unset(c, "adapter")
return ctx.forward(
c,
await ctx.success(c, { claims: state.claims as Claims }),
)
}
})
},
} satisfies Adapter<{ claims: Claims }>
}
export type ApiAdapterOptions = Parameters<typeof ApiAdapter>[0]

View File

@@ -0,0 +1,62 @@
import { z } from "zod";
import { Hono } from "hono";
import { notPublic } from "./auth";
import { Result } from "../common";
import { resolver } from "hono-openapi/zod";
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";
export module AccountApi {
export const route = new Hono()
.use(notPublic)
.get("/",
describeRoute({
tags: ["Account"],
summary: "Retrieve the current user's details",
description: "Returns the user's account details, plus the teams they have joined",
responses: {
200: {
content: {
"application/json": {
schema: Result(
z.object({
...User.Info.shape,
teams: Team.Info.array(),
})
),
},
},
description: "Successfully retrieved account details"
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "This account does not exist",
},
}
}),
async (c) => {
const actor = assertActor("user");
const currentUser = await User.fromID(actor.properties.userID)
if (!currentUser) return c.json({ error: "This account does not exist, it may have been deleted" }, 404)
const { id, email, name, polarCustomerID, avatarUrl, discriminator } = currentUser
return c.json({
data: {
id,
email,
name,
avatarUrl,
discriminator,
polarCustomerID,
teams: await User.teams(),
}
}, 200);
},
)
}

View File

@@ -0,0 +1,69 @@
import { Resource } from "sst";
import { subjects } from "../subjects";
import { type MiddlewareHandler } from "hono";
// import { User } from "@nestri/core/user/index";
import { VisibleError } from "@nestri/core/error";
import { HTTPException } from "hono/http-exception";
import { useActor, withActor } from "@nestri/core/actor";
import { createClient } from "@openauthjs/openauth/client";
const client = createClient({
issuer: Resource.Urls.auth,
clientID: "api",
});
export const notPublic: MiddlewareHandler = async (c, next) => {
const actor = useActor();
if (actor.type === "public")
throw new HTTPException(401, { message: "Unauthorized" });
return next();
};
export const auth: MiddlewareHandler = async (c, next) => {
const authHeader =
c.req.query("authorization") ?? c.req.header("authorization");
if (!authHeader) return next();
const match = authHeader.match(/^Bearer (.+)$/);
if (!match) {
throw new VisibleError(
"auth.token",
"Bearer token not found or improperly formatted",
);
}
const bearerToken = match[1];
let result = await client.verify(subjects, bearerToken!);
if (result.err) {
throw new HTTPException(401, {
message: "Unauthorized",
});
}
if (result.subject.type === "user") {
const teamID = c.req.header("x-nestri-team") //|| c.req.query("teamID");
if (!teamID) return withActor(result.subject, next);
// const email = result.subject.properties.email;
return withActor(
{
type: "system",
properties: {
teamID,
},
},
next
// async () => {
// const user = await User.fromEmail(email);
// if (!user || user.length === 0) {
// c.status(401);
// return c.text("Unauthorized");
// }
// return withActor(
// {
// type: "member",
// properties: { userID: user[0].id, workspaceID: user.workspaceID },
// },
// next,
// );
// },
);
}
};

View File

@@ -1,264 +0,0 @@
// import { z } from "zod";
// import { Hono } from "hono";
// import { Result } from "../common";
// import { describeRoute } from "hono-openapi";
// import { Games } from "@nestri/core/game/index";
// import { Examples } from "@nestri/core/examples";
// import { validator, resolver } from "hono-openapi/zod";
// import { Sessions } from "@nestri/core/session/index";
// export module GameApi {
// export const route = new Hono()
// .get(
// "/",
// //FIXME: Add a way to filter through query params
// describeRoute({
// tags: ["Game"],
// summary: "Retrieve all games in the user's library",
// description: "Returns a list of all (known) games associated with the authenticated user",
// responses: {
// 200: {
// content: {
// // "application/json": {
// schema: Result(
// Games.Info.array().openapi({
// description: "A list of games owned by the user",
// example: [Examples.Game],
// }),
// ),
// },
// },
// description: "Successfully retrieved the user's library of games",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "No games were found in the authenticated user's library",
// },
// },
// }),
// async (c) => {
// const games = await Games.list();
// if (!games) return c.json({ error: "No games exist in this user's library" }, 404);
// return c.json({ data: games }, 200);
// },
// )
// .get(
// "/:steamID",
// describeRoute({
// tags: ["Game"],
// summary: "Retrieve a game by its Steam ID",
// description: "Fetches detailed metadata about a specific game using its Steam ID",
// responses: {
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "No game found matching the provided Steam ID",
// },
// 200: {
// content: {
// "application/json": {
// schema: Result(
// Games.Info.openapi({
// description: "Detailed metadata about the requested game",
// example: Examples.Game,
// }),
// ),
// },
// },
// description: "Successfully retrieved game metadata",
// },
// },
// }),
// validator(
// "param",
// z.object({
// steamID: Games.Info.shape.steamID.openapi({
// description: "The unique Steam ID used to identify a game",
// example: Examples.Game.steamID,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param");
// const game = await Games.fromSteamID(params.steamID);
// if (!game) return c.json({ error: "Game not found" }, 404);
// return c.json({ data: game }, 200);
// },
// )
// .post(
// "/:steamID",
// describeRoute({
// tags: ["Game"],
// summary: "Add a game to the user's library using its Steam ID",
// description: "Adds a game to the currently authenticated user's library. Once added, the user can play the game and share their progress with others",
// responses: {
// 200: {
// content: {
// "application/json": {
// schema: Result(z.literal("ok"))
// },
// },
// description: "Game successfully added to user's library",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "No game was found matching the provided Steam ID",
// },
// },
// }),
// validator(
// "param",
// z.object({
// steamID: Games.Info.shape.steamID.openapi({
// description: "The unique Steam ID of the game to be added to the current user's library",
// example: Examples.Game.steamID,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param")
// const game = await Games.fromSteamID(params.steamID)
// if (!game) return c.json({ error: "Game not found" }, 404);
// const res = await Games.linkToCurrentUser(game.id)
// return c.json({ data: res }, 200);
// },
// )
// .delete(
// "/:steamID",
// describeRoute({
// tags: ["Game"],
// summary: "Remove game from user's library",
// description: "Removes a game from the authenticated user's library. The game remains in the system but will no longer be accessible to the user",
// responses: {
// 200: {
// content: {
// "application/json": {
// schema: Result(z.literal("ok")),
// },
// },
// description: "Game successfully removed from library",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "The game with the specified Steam ID was not found",
// },
// }
// }),
// validator(
// "param",
// z.object({
// steamID: Games.Info.shape.steamID.openapi({
// description: "The Steam ID of the game to be removed",
// example: Examples.Game.steamID,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param");
// const res = await Games.unLinkFromCurrentUser(params.steamID)
// if (!res) return c.json({ error: "Game not found the library" }, 404);
// return c.json({ data: res }, 200);
// },
// )
// .put(
// "/",
// describeRoute({
// tags: ["Game"],
// summary: "Update game metadata",
// description: "Updates the metadata about a specific game using its Steam ID",
// responses: {
// 200: {
// content: {
// "application/json": {
// schema: Result(z.literal("ok")),
// },
// },
// description: "Game successfully updated",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "The game with the specified Steam ID was not found",
// },
// }
// }),
// validator(
// "json",
// Games.Info.omit({ id: true }).openapi({
// description: "Game information",
// //@ts-expect-error
// example: { ...Examples.Game, id: undefined }
// })
// ),
// async (c) => {
// const params = c.req.valid("json");
// const res = await Games.create(params)
// if (!res) return c.json({ error: "Something went seriously wrong" }, 404);
// return c.json({ data: res }, 200);
// },
// )
// .get(
// "/:steamID/sessions",
// describeRoute({
// tags: ["Game"],
// summary: "Retrieve game sessions by the associated game's Steam ID",
// description: "Fetches active and public game sessions associated with a specific game using its Steam ID",
// responses: {
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "This game does not have nay publicly active sessions",
// },
// 200: {
// content: {
// "application/json": {
// schema: Result(
// Sessions.Info.array().openapi({
// description: "Publicly active sessions associated with the game",
// example: [Examples.Session],
// }),
// ),
// },
// },
// description: "Successfully retrieved game sessions associated with this game",
// },
// },
// }),
// validator(
// "param",
// z.object({
// steamID: Games.Info.shape.steamID.openapi({
// description: "The unique Steam ID used to identify a game",
// example: Examples.Game.steamID,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param");
// const sessions = await Sessions.fromSteamID(params.steamID);
// if (!sessions) return c.json({ error: "This game does not have any publicly active game sessions" }, 404);
// return c.json({ data: sessions }, 200);
// },
// );
// }

View File

@@ -1,79 +1,13 @@
import "zod-openapi/extend";
import { Resource } from "sst";
import { Hono } from "hono";
import { auth } from "./auth";
import { ZodError } from "zod";
import { UserApi } from "./user";
import { TaskApi } from "./task";
// import { GameApi } from "./game";
// import { TeamApi } from "./team";
import { logger } from "hono/logger";
import { subjects } from "../subjects";
import { SessionApi } from "./session";
// import { MachineApi } from "./machine";
import { AccountApi } from "./account";
import { openAPISpecs } from "hono-openapi";
import { SubscriptionApi } from "./subscription";
import { VisibleError } from "@nestri/core/error";
import { ActorContext } from '@nestri/core/actor';
import { Hono, type MiddlewareHandler } from "hono";
import { HTTPException } from "hono/http-exception";
import { createClient } from "@openauthjs/openauth/client";
const auth: MiddlewareHandler = async (c, next) => {
const client = createClient({
clientID: "api",
issuer: Resource.Urls.auth
});
const authHeader =
c.req.query("authorization") ?? c.req.header("authorization");
if (authHeader) {
const match = authHeader.match(/^Bearer (.+)$/);
if (!match || !match[1]) {
throw new VisibleError(
"input",
"auth.token",
"Bearer token not found or improperly formatted",
);
}
const bearerToken = match[1];
const result = await client.verify(subjects, bearerToken!);
if (result.err)
throw new VisibleError("input", "auth.invalid", "Invalid bearer token");
if (result.subject.type === "user") {
return ActorContext.with(
{
type: "user",
properties: {
userID: result.subject.properties.userID,
accessToken: result.subject.properties.accessToken,
auth: {
type: "oauth",
clientID: result.aud,
},
},
},
next,
);
} else if (result.subject.type === "device") {
return ActorContext.with(
{
type: "device",
properties: {
hostname: result.subject.properties.hostname,
teamSlug: result.subject.properties.teamSlug,
auth: {
type: "oauth",
clientID: result.aud,
},
},
},
next,
);
}
}
return ActorContext.with({ type: "public", properties: {} }, next);
};
import { handle, streamHandle } from "hono/aws-lambda";
const app = new Hono();
@@ -85,14 +19,8 @@ app
.use(auth)
const routes = app
.get("/", (c) => c.text("Hello there 👋🏾"))
.route("/users", UserApi.route)
.route("/tasks", TaskApi.route)
// .route("/teams", TeamApi.route)
// .route("/games", GameApi.route)
.route("/sessions", SessionApi.route)
// .route("/machines", MachineApi.route)
.route("/subscriptions", SubscriptionApi.route)
.get("/", (c) => c.text("Hello World!"))
.route("/account", AccountApi.route)
.onError((error, c) => {
console.warn(error);
if (error instanceof VisibleError) {
@@ -101,7 +29,7 @@ const routes = app
code: error.code,
message: error.message,
},
error.kind === "auth" ? 401 : 400,
400
);
}
if (error instanceof ZodError) {
@@ -151,9 +79,15 @@ app.get(
scheme: "bearer",
bearerFormat: "JWT",
},
TeamID: {
type: "apiKey",
description:"The team ID to use for this query",
in: "header",
name: "x-nestri-team"
},
},
},
security: [{ Bearer: [] }],
security: [{ Bearer: [], TeamID:[] }],
servers: [
{ description: "Production", url: "https://api.nestri.io" },
],
@@ -162,4 +96,4 @@ app.get(
);
export type Routes = typeof routes;
export default app
export const handler = process.env.SST_DEV ? handle(app) : streamHandle(app);

View File

@@ -1,176 +0,0 @@
// import { z } from "zod";
// import { Hono } from "hono";
// import { Result } from "../common";
// import { describeRoute } from "hono-openapi";
// import { Examples } from "@nestri/core/examples";
// import { validator, resolver } from "hono-openapi/zod";
// import { Machines } from "@nestri/core/machine/index";
// export module MachineApi {
// export const route = new Hono()
// .get(
// "/",
// //FIXME: Add a way to filter through query params
// describeRoute({
// tags: ["Machine"],
// summary: "Retrieve all machines",
// description: "Returns a list of all machines registered to the authenticated user in the Nestri network",
// responses: {
// 200: {
// content: {
// "application/json": {
// schema: Result(
// // Machines.Info.array().openapi({
// description: "A list of machines associated with the user",
// example: [Examples.Machine],
// }),
// ),
// },
// },
// description: "Successfully retrieved the list of machines",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "No machines found for the authenticated user",
// },
// },
// }),
// async (c) => {
// const machines = await Machines.list();
// if (!machines) return c.json({ error: "No machines found for this user" }, 404);
// return c.json({ data: machines }, 200);
// },
// )
// .get(
// "/:fingerprint",
// describeRoute({
// tags: ["Machine"],
// summary: "Retrieve machine by fingerprint",
// description: "Fetches detailed information about a specific machine using its unique fingerprint derived from the Linux machine ID",
// responses: {
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "No machine found matching the provided fingerprint",
// },
// 200: {
// content: {
// "application/json": {
// schema: Result(
// Machines.Info.openapi({
// description: "Detailed information about the requested machine",
// example: Examples.Machine,
// }),
// ),
// },
// },
// description: "Successfully retrieved machine information",
// },
// },
// }),
// validator(
// "param",
// z.object({
// fingerprint: Machines.Info.shape.fingerprint.openapi({
// description: "The unique fingerprint used to identify the machine, derived from its Linux machine ID",
// example: Examples.Machine.fingerprint,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param");
// const machine = await Machines.fromFingerprint(params.fingerprint);
// if (!machine) return c.json({ error: "Machine not found" }, 404);
// return c.json({ data: machine }, 200);
// },
// )
// .post(
// "/:fingerprint",
// describeRoute({
// tags: ["Machine"],
// summary: "Register a machine to an owner",
// description: "Associates a machine with the currently authenticated user's account, enabling them to manage and control the machine",
// responses: {
// 200: {
// content: {
// "application/json": {
// schema: Result(z.literal("ok"))
// },
// },
// description: "Machine successfully registered to user's account",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "No machine found matching the provided fingerprint",
// },
// },
// }),
// validator(
// "param",
// z.object({
// fingerprint: Machines.Info.shape.fingerprint.openapi({
// description: "The unique fingerprint of the machine to be registered, derived from its Linux machine ID",
// example: Examples.Machine.fingerprint,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param")
// const machine = await Machines.fromFingerprint(params.fingerprint)
// if (!machine) return c.json({ error: "Machine not found" }, 404);
// const res = await Machines.linkToCurrentUser(machine.id)
// return c.json({ data: res }, 200);
// },
// )
// .delete(
// "/:fingerprint",
// describeRoute({
// tags: ["Machine"],
// summary: "Unregister machine from user",
// description: "Removes the association between a machine and the authenticated user's account. This does not delete the machine itself, but removes the user's ability to manage it",
// responses: {
// 200: {
// content: {
// "application/json": {
// schema: Result(z.literal("ok")),
// },
// },
// description: "Machine successfully unregistered from user's account",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "The machine with the specified fingerprint was not found",
// },
// }
// }),
// validator(
// "param",
// z.object({
// fingerprint: Machines.Info.shape.fingerprint.openapi({
// description: "The unique fingerprint of the machine to be unregistered, derived from its Linux machine ID",
// example: Examples.Machine.fingerprint,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param");
// const res = await Machines.unLinkFromCurrentUser(params.fingerprint)
// if (!res) return c.json({ error: "Machine not found for this user" }, 404);
// return c.json({ data: res }, 200);
// },
// );
// }

View File

@@ -1,175 +0,0 @@
import { z } from "zod";
import { Hono } from "hono";
import { Result } from "../common";
import { describeRoute } from "hono-openapi";
import { Examples } from "@nestri/core/examples";
import { validator, resolver } from "hono-openapi/zod";
import { Sessions } from "@nestri/core/session/index";
export module SessionApi {
export const route = new Hono()
.get(
"/active",
describeRoute({
tags: ["Session"],
summary: "Retrieve all active gaming sessions",
description: "Returns a list of all active gaming sessions associated with the authenticated user",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Sessions.Info.array().openapi({
description: "A list of active gaming sessions associated with the user",
example: [{ ...Examples.Session, public: true, endedAt: undefined }],
}),
),
},
},
description: "Successfully retrieved the list of active gaming sessions",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No active gaming sessions found for the authenticated user",
},
},
}),
async (c) => {
const res = await Sessions.getActive();
if (!res) return c.json({ error: "No active gaming sessions found for this user" }, 404);
return c.json({ data: res }, 200);
},
)
.get(
"/:id",
describeRoute({
tags: ["Session"],
summary: "Retrieve a gaming session by id",
description: "Fetches detailed information about a specific gaming session using its unique id",
responses: {
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No gaming session found matching the provided id",
},
200: {
content: {
"application/json": {
schema: Result(
Sessions.Info.openapi({
description: "Detailed information about the requested gaming session",
example: Examples.Session,
}),
),
},
},
description: "Successfully retrieved gaming session information",
},
},
}),
validator(
"param",
z.object({
id: Sessions.Info.shape.id.openapi({
description: "The unique id used to identify the gaming session",
example: Examples.Session.id,
}),
}),
),
async (c) => {
const params = c.req.valid("param");
const res = await Sessions.fromID(params.id);
if (!res) return c.json({ error: "Session not found" }, 404);
return c.json({ data: res }, 200);
},
)
.post(
"/",
describeRoute({
tags: ["Session"],
summary: "Create a new gaming session for this user",
description: "Create a new gaming session for the currently authenticated user, enabling them to play a game",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok"))
},
},
description: "Gaming session successfully created",
},
422: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "Something went wrong while creating a gaming session for this user",
},
},
}),
validator(
"json",
z.object({
public: Sessions.Info.shape.public.openapi({
description: "Whether the session is publicly viewable by all users. If false, only authorized users can access it",
example: Examples.Session.public
}),
}),
),
async (c) => {
const params = c.req.valid("json")
const session = await Sessions.create(params)
if (!session) return c.json({ error: "Something went wrong while creating a session" }, 422);
return c.json({ data: session }, 200);
},
)
.delete(
"/:id",
describeRoute({
tags: ["Session"],
summary: "Terminate a gaming session",
description: "This endpoint allows a user to terminate an active gaming session by providing the session's unique ID",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok")),
},
},
description: "The session was successfully terminated.",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "The session with the specified ID could not be found by this user",
},
}
}),
validator(
"param",
z.object({
id: Sessions.Info.shape.id.openapi({
description: "The unique identifier of the gaming session to be terminated. ",
example: Examples.Session.id,
}),
}),
),
async (c) => {
const params = c.req.valid("param");
const res = await Sessions.end(params.id)
if (!res) return c.json({ error: "Session is not owned by this user" }, 404);
return c.json({ data: res }, 200);
},
);
}

View File

@@ -1,130 +0,0 @@
import { z } from "zod";
import { Hono } from "hono";
import { Result } from "../common";
import { describeRoute } from "hono-openapi";
import { Examples } from "@nestri/core/examples";
import { validator, resolver } from "hono-openapi/zod";
import { Subscriptions } from "@nestri/core/subscription/index";
export module SubscriptionApi {
export const route = new Hono()
.get(
"/",
describeRoute({
tags: ["Subscription"],
summary: "List subscriptions",
description: "List the subscriptions associated with the current user.",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Subscriptions.Info.array().openapi({
description: "List of subscriptions.",
example: [Examples.Subscription],
}),
),
},
},
description: "List of subscriptions.",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No subscriptions found for this user",
},
},
}),
async (c) => {
const data = await Subscriptions.list(undefined);
if (!data) return c.json({ error: "No subscriptions found for this user" }, 404);
return c.json({ data }, 200);
},
)
.post(
"/",
describeRoute({
tags: ["Subscription"],
summary: "Subscribe",
description: "Create a subscription for the current user.",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok")),
},
},
description: "Subscription was created successfully.",
},
400: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "Subscription already exists.",
},
},
}),
validator(
"json",
z.object({
checkoutID: Subscriptions.Info.shape.id.openapi({
description: "The checkout id information.",
example: Examples.Subscription.id,
})
}),
),
async (c) => {
const body = c.req.valid("json");
const data = await Subscriptions.fromCheckoutID(body.checkoutID)
if (data) return c.json({ error: "Subscription already exists" })
await Subscriptions.create(body);
return c.json({ data: "ok" as const }, 200);
},
)
.delete(
"/:id",
describeRoute({
tags: ["Subscription"],
summary: "Cancel",
description: "Cancel a subscription for the current user.",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok")),
},
},
description: "Subscription was cancelled successfully.",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "Subscription not found.",
},
},
}),
validator(
"param",
z.object({
id: Subscriptions.Info.shape.id.openapi({
description: "ID of the subscription to cancel.",
example: Examples.Subscription.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const subscription = await Subscriptions.fromID(param.id);
if (!subscription) return c.json({ error: "Subscription not found" }, 404);
await Subscriptions.remove(param.id);
return c.json({ data: "ok" as const }, 200);
},
);
}

View File

@@ -1,277 +0,0 @@
import { z } from "zod";
import { Hono } from "hono";
import { Result } from "../common";
import { describeRoute } from "hono-openapi";
import { Tasks } from "@nestri/core/task/index";
import { Examples } from "@nestri/core/examples";
import { validator, resolver } from "hono-openapi/zod";
import { useCurrentUser } from "@nestri/core/actor";
import { Subscriptions } from "@nestri/core/subscription/index";
import { Sessions } from "@nestri/core/session/index";
export module TaskApi {
export const route = new Hono()
.get("/",
describeRoute({
tags: ["Task"],
summary: "List Tasks",
description: "List all tasks by this user",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Tasks.Info.openapi({
description: "A task example gotten from this task id",
examples: [Examples.Task],
}))
},
},
description: "Tasks owned by this user were found",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No tasks for this user were not found.",
},
},
}),
async (c) => {
const task = await Tasks.list();
if (!task) return c.json({ error: "No tasks were found for this user" }, 404);
return c.json({ data: task }, 200);
},
)
.get("/:id",
describeRoute({
tags: ["Task"],
summary: "Get Task",
description: "Get a task by its id",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Tasks.Info.openapi({
description: "A task example gotten from this task id",
example: Examples.Task,
}))
},
},
description: "A task with this id was found",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "A task with this id was not found.",
},
},
}),
validator(
"param",
z.object({
id: Tasks.Info.shape.id.openapi({
description: "ID of the task to get",
example: Examples.Task.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const task = await Tasks.fromID(param.id);
if (!task) return c.json({ error: "Task was not found" }, 404);
return c.json({ data: task }, 200);
},
)
.get("/:id/session",
describeRoute({
tags: ["Task"],
summary: "Get the current session running on this task",
description: "Get a task by its id",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Sessions.Info.openapi({
description: "A session running on this task",
example: Examples.Session,
}))
},
},
description: "A task with this id was found",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "A task with this id was not found.",
},
},
}),
validator(
"param",
z.object({
id: Tasks.Info.shape.id.openapi({
description: "ID of the task to get session information about",
example: Examples.Task.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const task = await Tasks.fromID(param.id);
if (!task) return c.json({ error: "Task was not found" }, 404);
const session = await Sessions.fromTaskID(task.id)
if (!session) return c.json({ error: "No session was found running on this task" }, 404);
return c.json({ data: session }, 200);
},
)
.delete("/:id",
describeRoute({
tags: ["Task"],
summary: "Stop Task",
description: "Stop a running task by its id",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok"))
},
},
description: "A task with this id was found",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "A task with this id was not found.",
},
},
}),
validator(
"param",
z.object({
id: Tasks.Info.shape.id.openapi({
description: "The id of the task to get",
example: Examples.Task.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const task = await Tasks.fromID(param.id);
if (!task) return c.json({ error: "Task was not found" }, 404);
//End any running tasks then (and only then) kill the task
const session = await Sessions.fromTaskID(task.id)
if (session) { await Sessions.end(session.id) }
const res = await Tasks.stop({ taskID: task.taskID, id: param.id })
if (!res) return c.json({ error: "Something went wrong trying to stop the task" }, 404);
return c.json({ data: "ok" }, 200);
},
)
.post("/",
describeRoute({
tags: ["Task"],
summary: "Create Task",
description: "Create a task",
responses: {
200: {
content: {
"application/json": {
schema: Result(Tasks.Info.shape.id.openapi({
description: "The id of the task created",
example: Examples.Task.id,
}))
},
},
description: "A task with this id was created",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "A task with this id could not be created",
},
401: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "You are not authorised to do this",
},
},
}),
async (c) => {
const user = useCurrentUser();
// const data = await Subscriptions.list(undefined);
// if (!data) return c.json({ error: "You need a subscription to create a task" }, 404);
if (user) {
const task = await Tasks.create();
if (!task) return c.json({ error: "Task could not be created" }, 404);
return c.json({ data: task }, 200);
}
return c.json({ error: "You are not authorized to do this" }, 401);
},
)
.put(
"/:id",
describeRoute({
tags: ["Task"],
summary: "Get an update on a task",
description: "Updates the metadata about a task by querying remote task",
responses: {
200: {
content: {
"application/json": {
schema: Result(Tasks.Info.openapi({
description: "The updated information about this task",
example: Examples.Task
})),
},
},
description: "Task successfully updated",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "The task specified id was not found",
},
}
}),
validator(
"param",
z.object({
id: Tasks.Info.shape.id.openapi({
description: "The id of the task to update on",
example: Examples.Task.id
})
})
),
async (c) => {
const params = c.req.valid("param");
const res = await Tasks.update(params.id)
if (!res) return c.json({ error: "Something went seriously wrong" }, 404);
return c.json({ data: res[0] }, 200);
},
)
}

View File

@@ -1,238 +0,0 @@
import { z } from "zod";
import { Hono } from "hono";
import { Result } from "../common";
import { describeRoute } from "hono-openapi";
import { Teams } from "@nestri/core/team/index";
import { Users } from "@nestri/core/user/index";
import { Examples } from "@nestri/core/examples";
import { validator, resolver } from "hono-openapi/zod";
export module TeamApi {
export const route = new Hono()
.get(
"/",
//FIXME: Add a way to filter through query params
describeRoute({
tags: ["Team"],
summary: "Retrieve all teams",
description: "Returns a list of all teams which the authenticated user is part of",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Teams.Info.array().openapi({
description: "A list of teams associated with the user",
example: [Examples.Team],
}),
),
},
},
description: "Successfully retrieved the list teams",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No teams found for the authenticated user",
},
},
}),
async (c) => {
const teams = await Teams.list();
if (!teams) return c.json({ error: "No teams found for this user" }, 404);
return c.json({ data: teams }, 200);
},
)
.get(
"/:slug",
describeRoute({
tags: ["Team"],
summary: "Retrieve a team by slug",
description: "Fetch detailed information about a specific team using its unique slug",
responses: {
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No team found matching the provided slug",
},
200: {
content: {
"application/json": {
schema: Result(
Teams.Info.openapi({
description: "Detailed information about the requested team",
example: Examples.Team,
}),
),
},
},
description: "Successfully retrieved the team information",
},
},
}),
validator(
"param",
z.object({
slug: Teams.Info.shape.slug.openapi({
description: "The unique slug used to identify the team",
example: Examples.Team.slug,
}),
}),
),
async (c) => {
const params = c.req.valid("param");
const team = await Teams.fromSlug(params.slug);
if (!team) return c.json({ error: "Team not found" }, 404);
return c.json({ data: team }, 200);
},
)
.post(
"/",
describeRoute({
tags: ["Team"],
summary: "Create a team",
description: "Create a new team for the currently authenticated user, enabling them to invite and play a game together with friends",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok"))
},
},
description: "Team successfully created",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "A team with this slug already exists",
},
},
}),
validator(
"json",
z.object({
slug: Teams.Info.shape.slug.openapi({
description: "The unique name to be used with this team",
example: Examples.Team.slug
}),
name: Teams.Info.shape.name.openapi({
description: "The human readable name to give this team",
example: Examples.Team.name
})
})
),
async (c) => {
const params = c.req.valid("json")
const team = await Teams.fromSlug(params.slug)
if (team) return c.json({ error: "A team with this slug already exists" }, 404);
const res = await Teams.create(params)
return c.json({ data: res }, 200);
},
)
.delete(
"/:slug",
describeRoute({
tags: ["Team"],
summary: "Delete a team",
description: "This endpoint allows a user to delete a team, by providing it's unique slug",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok")),
},
},
description: "The team was successfully deleted.",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "A team with this slug does not exist",
},
401: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "Your are not authorized to delete this team",
},
}
}),
validator(
"param",
z.object({
slug: Teams.Info.shape.slug.openapi({
description: "The unique slug of the team to be deleted. ",
example: Examples.Team.slug,
}),
}),
),
async (c) => {
const params = c.req.valid("param");
const team = await Teams.fromSlug(params.slug)
if (!team) return c.json({ error: "Team not found" }, 404);
// if (!team.owner) return c.json({ error: "Your are not authorised to delete this team" }, 401)
const res = await Teams.remove(team.id);
return c.json({ data: res }, 200);
},
)
.post(
"/:slug/invite/:email",
describeRoute({
tags: ["Team"],
summary: "Invite a user to a team",
description: "Invite a user to a team owned by the current user",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok")),
},
},
description: "User successfully invited",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "The game with the specified Steam ID was not found",
},
}
}),
validator(
"param",
z.object({
slug: Teams.Info.shape.slug.openapi({
description: "The unique slug of the team the user wants to invite ",
example: Examples.Team.slug,
}),
email: Users.Info.shape.email.openapi({
description: "The email of the user to invite",
example: Examples.User.email
})
}),
),
async (c) => {
const params = c.req.valid("param");
const team = await Teams.fromSlug(params.slug)
if (!team) return c.json({ error: "Team not found" }, 404);
// if (!team.owner) return c.json({ error: "Your are not authorized to delete this team" }, 401)
return c.json({ data: "ok" }, 200);
},
)
}

View File

@@ -1,177 +0,0 @@
import { z } from "zod";
import { Hono } from "hono";
import { Result } from "../common";
import { describeRoute } from "hono-openapi";
import { Examples } from "@nestri/core/examples";
import { Profiles } from "@nestri/core/profile/index";
import { validator, resolver } from "hono-openapi/zod";
import { Sessions } from "@nestri/core/session/index";
export module UserApi {
export const route = new Hono()
.get(
"/@me",
describeRoute({
tags: ["User"],
summary: "Retrieve current user's profile",
description: "Returns the current authenticate user's profile",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Profiles.Info.openapi({
description: "The profile for this user",
example: Examples.Profile,
}),
),
},
},
description: "Successfully retrieved the user's profile",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No user profile found",
},
},
}), async (c) => {
const profile = await Profiles.getCurrentProfile();
if (!profile) return c.json({ error: "No profile found for this user" }, 404);
return c.json({ data: profile }, 200);
},
)
.get(
"/",
describeRoute({
tags: ["User"],
summary: "List all user profiles",
description: "Returns all user profiles",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Profiles.Info.openapi({
description: "The profiles of all users",
examples: [Examples.Profile],
}),
),
},
},
description: "Successfully retrieved all user profiles",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No user profiles were found",
},
},
}), async (c) => {
const profiles = await Profiles.list();
if (!profiles) return c.json({ error: "No user profiles were found" }, 404);
return c.json({ data: profiles }, 200);
},
)
.get(
"/:id",
describeRoute({
tags: ["User"],
summary: "Retrieve a user's profile",
description: "Gets a user's profile by their id",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Profiles.Info.openapi({
description: "The profile of the users",
example: Examples.Profile,
}),
),
},
},
description: "Successfully retrieved the user profile",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No user profile was found",
},
},
}),
validator(
"param",
z.object({
id: Profiles.Info.shape.id.openapi({
description: "ID of the user profile to get",
example: Examples.Profile.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
console.log("id", param.id)
const profiles = await Profiles.fromID(param.id);
if (!profiles) return c.json({ error: "No user profile was found" }, 404);
return c.json({ data: profiles }, 200);
},
)
.get(
"/:id/session",
describeRoute({
tags: ["User"],
summary: "Retrieve a user's active session",
description: "Get a user's active gaming session details by their id",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Sessions.Info.openapi({
description: "The active session of this user",
example: Examples.Session,
}),
),
},
},
description: "Successfully retrieved the active user gaming session",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No active gaming session for this user",
},
},
}),
validator(
"param",
z.object({
id: Sessions.Info.shape.id.openapi({
description: "ID of the user's gaming session to get",
example: Examples.Session.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const ownerID = await Profiles.fromIDToOwner(param.id);
if (!ownerID) return c.json({ error: "We could not get the owner of this profile" }, 404);
const session = await Sessions.fromOwnerID(ownerID)
if(!session) return c.json({ error: "This user profile does not have active sessions" }, 404);
return c.json({ data: session }, 200);
},
)
}

View File

@@ -1,40 +1,17 @@
import { Resource } from "sst"
import {
type ExecutionContext,
type KVNamespace,
} from "@cloudflare/workers-types"
import { Select } from "./ui/select";
import { subjects } from "./subjects"
import { logger } from "hono/logger";
import { handle } from "hono/aws-lambda";
import { PasswordUI } from "./ui/password"
import { Email } from "@nestri/core/email/index"
import { Users } from "@nestri/core/user/index"
import { Teams } from "@nestri/core/team/index"
import { authorizer } from "@openauthjs/openauth"
import { Profiles } from "@nestri/core/profile/index"
import { issuer } from "@openauthjs/openauth";
import { User } from "@nestri/core/user/index"
import { Email } from "@nestri/core/email/index";
import { handleDiscord, handleGithub } from "./utils";
import { type CFRequest } from "@nestri/core/types"
import { GithubAdapter } from "./ui/adapters/github";
import { DiscordAdapter } from "./ui/adapters/discord";
import { Instances } from "@nestri/core/instance/index"
import { PasswordAdapter } from "./ui/adapters/password"
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
import { Subscriptions } from "@nestri/core/subscription/index";
import type { Subscription } from "./type";
interface Env {
CloudflareAuthKV: KVNamespace
}
export type CodeAdapterState =
| {
type: "start"
}
| {
type: "code"
resend?: boolean
code: string
claims: Record<string, string>
}
import { type Provider } from "@openauthjs/openauth/provider/provider"
type OauthUser = {
primary: {
@@ -45,156 +22,176 @@ type OauthUser = {
avatar: any;
username: any;
}
export default {
async fetch(request: CFRequest, env: Env, ctx: ExecutionContext) {
// const location = `${request.cf.country},${request.cf.continent}`
return authorizer({
select: Select({
providers: {
device: {
hide: true,
},
},
}),
theme: {
title: "Nestri | Auth",
primary: "#FF4F01",
//TODO: Change this in prod
logo: "https://nestri.io/logo.webp",
favicon: "https://nestri.io/seo/favicon.ico",
background: {
light: "#f5f5f5 ",
dark: "#171717"
},
radius: "lg",
font: {
family: "Geist, sans-serif",
},
css: `
const app = issuer({
select: Select({
providers: {
device: {
hide: true,
},
},
}),
theme: {
title: "Nestri | Auth",
primary: "#FF4F01",
//TODO: Change this in prod
logo: "https://nestri.io/logo.webp",
favicon: "https://nestri.io/seo/favicon.ico",
background: {
light: "#f5f5f5 ",
dark: "#171717"
},
radius: "lg",
font: {
family: "Geist, sans-serif",
},
css: `
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100;200;300;400;500;600;700;800;900&display=swap');
`,
},
storage: CloudflareStorage({
namespace: env.CloudflareAuthKV,
},
subjects,
providers: {
github: GithubAdapter({
clientID: Resource.GithubClientID.value,
clientSecret: Resource.GithubClientSecret.value,
scopes: ["user:email"]
}),
discord: DiscordAdapter({
clientID: Resource.DiscordClientID.value,
clientSecret: Resource.DiscordClientSecret.value,
scopes: ["email", "identify"]
}),
password: PasswordAdapter(
PasswordUI({
sendCode: async (email, code) => {
console.log("email & code:", email, code)
await Email.send(
"auth",
email,
`Nestri code: ${code}`,
`Your Nestri login code is ${code}`,
)
},
}),
subjects,
providers: {
github: GithubAdapter({
clientID: Resource.GithubClientID.value,
clientSecret: Resource.GithubClientSecret.value,
scopes: ["user:email"]
}),
discord: DiscordAdapter({
clientID: Resource.DiscordClientID.value,
clientSecret: Resource.DiscordClientSecret.value,
scopes: ["email", "identify"]
}),
password: PasswordAdapter(
PasswordUI({
sendCode: async (email, code) => {
console.log("email & code:", email, code)
await Email.send(email, code)
},
}),
),
device: {
type: "device",
async client(input) {
if (input.clientSecret !== Resource.AuthFingerprintKey.value) {
throw new Error("Invalid authorization token");
}
const teamSlug = input.params.team;
if (!teamSlug) {
throw new Error("Team slug is required");
}
const hostname = input.params.hostname;
if (!hostname) {
throw new Error("Hostname is required");
}
return {
hostname,
teamSlug
};
},
init() { }
} as Adapter<{ teamSlug: string; hostname: string; }>,
},
allow: async (input) => {
const url = new URL(input.redirectURI);
const hostname = url.hostname;
if (hostname.endsWith("nestri.io")) return true;
if (hostname === "localhost") return true;
return false;
},
success: async (ctx, value) => {
if (value.provider === "device") {
const team = await Teams.fromSlug(value.teamSlug)
console.log("team", team)
console.log("teamSlug", value.teamSlug)
if (team) {
await Instances.create({ hostname: value.hostname, teamID: team.id })
return await ctx.subject("device", {
teamSlug: value.teamSlug,
hostname: value.hostname,
})
}
),
device: {
type: "device",
async client(input) {
if (input.clientSecret !== Resource.AuthFingerprintKey.value) {
throw new Error("Invalid authorization token");
}
const teamSlug = input.params.team;
if (!teamSlug) {
throw new Error("Team slug is required");
}
if (value.provider === "password") {
const email = value.email
const username = value.username
const token = await Users.create(email)
const usr = await Users.fromEmail(email);
const exists = await Profiles.fromOwnerID(usr.id)
if (username && !exists) {
await Profiles.create({ owner: usr.id, username })
}
const hostname = input.params.hostname;
if (!hostname) {
throw new Error("Hostname is required");
}
return await ctx.subject("user", {
accessToken: token,
userID: usr.id,
return {
hostname,
teamSlug
};
},
init() { }
} as Provider<{ teamSlug: string; hostname: string; }>,
},
allow: async (input) => {
const url = new URL(input.redirectURI);
const hostname = url.hostname;
if (hostname.endsWith("nestri.io")) return true;
if (hostname === "localhost") return true;
return false;
},
success: async (ctx, value) => {
// if (value.provider === "device") {
// const team = await Teams.fromSlug(value.teamSlug)
// console.log("team", team)
// console.log("teamSlug", value.teamSlug)
// if (team) {
// await Instances.create({ hostname: value.hostname, teamID: team.id })
// return await ctx.subject("device", {
// teamSlug: value.teamSlug,
// hostname: value.hostname,
// })
// }
// }
if (value.provider === "password") {
const email = value.email
const username = value.username
const matching = await User.fromEmail(email)
//Sign Up
if (username && !matching) {
const userID = await User.create({
name: username,
email,
});
if (!userID) throw new Error("Error creating user");
return ctx.subject("user", {
userID,
email
});
} else if (matching) {
//Sign In
return ctx.subject("user", {
userID: matching.id,
email
});
}
}
let user = undefined as OauthUser | undefined;
if (value.provider === "github") {
const access = value.tokenset.access;
user = await handleGithub(access)
}
if (value.provider === "discord") {
const access = value.tokenset.access
user = await handleDiscord(access)
}
if (user) {
try {
const matching = await User.fromEmail(user.primary.email);
//Sign Up
if (!matching) {
const userID = await User.create({
email: user.primary.email,
name: user.username,
avatarUrl: user.avatar
});
if (!userID) throw new Error("Error creating user");
return ctx.subject("user", {
userID,
email: user.primary.email
});
} else {
//Sign In
return await ctx.subject("user", {
userID: matching.id,
email: user.primary.email
});
}
let user = undefined as OauthUser | undefined;
} catch (error) {
console.error("error registering the user", error)
}
if (value.provider === "github") {
const access = value.tokenset.access;
user = await handleGithub(access)
}
}
if (value.provider === "discord") {
const access = value.tokenset.access
user = await handleDiscord(access)
}
throw new Error("Something went seriously wrong");
},
}).use(logger())
if (user) {
try {
const token = await Users.create(user.primary.email)
const usr = await Users.fromEmail(user.primary.email);
const exists = await Profiles.fromOwnerID(usr.id)
console.log("exists", exists)
if (!exists) {
await Profiles.create({ owner: usr.id, avatarUrl: user.avatar, username: user.username })
}
return await ctx.subject("user", {
accessToken: token,
userID: usr.id,
});
} catch (error) {
console.error("error registering the user", error)
}
}
throw new Error("Something went seriously wrong");
},
}).fetch(request, env, ctx)
}
}
export const handler = handle(app)

View File

@@ -0,0 +1,36 @@
import { bus } from "sst/aws/bus";
import { User } from "@nestri/core/user/index";
import { Email } from "@nestri/core/email/index"
import { useActor } from "@nestri/core/actor";
// import { Stripe } from "@nestri/core/stripe";
// import { Template } from "@nestri/core/email/template";
// import { EmailOctopus } from "@nestri/core/email-octopus";
export const handler = bus.subscriber(
[User.Events.Updated, User.Events.Created],
async (event) => {
console.log(event.type, event.properties, event.metadata);
switch (event.type) {
// case "order.created": {
// await Shippo.createShipment(event.properties.orderID);
// await Template.sendOrderConfirmation(event.properties.orderID);
// await EmailOctopus.addToCustomersList(event.properties.orderID);
// break;
// }
case "user.created": {
console.log("Send email here")
// const actor = useActor()
// if (actor.type !== "user") throw new Error("User actor is needed here")
// await Email.send(
// "welcome",
// actor.properties.email,
// `Welcome to Nestri`,
// `Welcome to Nestri`,
// )
// await Stripe.syncUser(event.properties.userID);
// // await EmailOctopus.addToMarketingList(event.properties.userID);
// break;
}
}
},
);

View File

@@ -1,14 +1,14 @@
import * as v from "valibot"
import { Subscription } from "./type"
import { createSubjects } from "@openauthjs/openauth"
import { createSubjects } from "@openauthjs/openauth/subject"
export const subjects = createSubjects({
user: v.object({
accessToken: v.string(),
userID: v.string()
email: v.string(),
userID: v.string(),
}),
device: v.object({
teamSlug: v.string(),
hostname: v.string(),
})
// device: v.object({
// teamSlug: v.string(),
// hostname: v.string(),
// })
})

View File

@@ -2,7 +2,7 @@
import { Layout } from "../base"
import { OauthError } from "@openauthjs/openauth/error"
import { getRelativeUrl } from "@openauthjs/openauth/util"
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
import { type Provider } from "@openauthjs/openauth/provider/provider"
export interface Oauth2Config {
type?: string
@@ -32,7 +32,7 @@ interface AdapterState {
export function Oauth2Adapter(
config: Oauth2Config,
): Adapter<{ tokenset: Oauth2Token; clientID: string }> {
): Provider<{ tokenset: Oauth2Token; clientID: string }> {
const query = config.query || {}
return {
type: config.type || "oauth2",

View File

@@ -1,7 +1,6 @@
import { Profiles } from "@nestri/core/profile/index"
import { UnknownStateError } from "@openauthjs/openauth/error"
// import { UnknownStateError } from "@openauthjs/openauth/error"
import { Storage } from "@openauthjs/openauth/storage/storage"
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
import { type Provider } from "@openauthjs/openauth/provider/provider"
import { generateUnbiasedDigits, timingSafeCompare } from "@openauthjs/openauth/random"
export interface PasswordHasher<T> {
@@ -309,7 +308,7 @@ export function PasswordAdapter(config: PasswordConfig) {
return transition({ type: "start", redirect: adapter.redirect })
})
},
} satisfies Adapter<{ email: string; username?:string }>
} satisfies Provider<{ email: string; username?:string }>
}
import * as jose from "jose"
@@ -378,6 +377,7 @@ export function PBKDF2Hasher(opts?: { interations?: number }): PasswordHasher<{
}
import { timingSafeEqual, randomBytes, scrypt } from "node:crypto"
import { getRelativeUrl } from "@openauthjs/openauth/util"
import { UnknownStateError } from "@openauthjs/openauth/error"
export function ScryptHasher(opts?: {
N?: number

View File

@@ -1,4 +1,6 @@
export const handleGithub = async (accessKey: string) => {
console.log("acceskey", accessKey)
const headers = {
Authorization: `token ${accessKey}`,
Accept: "application/vnd.github.v3+json",

View File

@@ -6,17 +6,34 @@
import "sst"
declare module "sst" {
export interface Resource {
"Api": {
"type": "sst.aws.Router"
"url": string
}
"ApiFn": {
"name": string
"type": "sst.aws.Function"
"url": string
}
"Auth": {
"type": "sst.aws.Auth"
"url": string
}
"AuthFingerprintKey": {
"type": "random.index/randomString.RandomString"
"value": string
}
"AwsAccessKey": {
"type": "sst.sst.Secret"
"value": string
"Bus": {
"arn": string
"name": string
"type": "sst.aws.Bus"
}
"AwsSecretKey": {
"type": "sst.sst.Secret"
"value": string
"Database": {
"host": string
"name": string
"password": string
"type": "sst.sst.Linkable"
"user": string
}
"DiscordClientID": {
"type": "sst.sst.Secret"
@@ -34,40 +51,25 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"InstantAdminToken": {
"Mail": {
"configSet": string
"sender": string
"type": "sst.aws.Email"
}
"PolarSecret": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAppId": {
"type": "sst.sst.Secret"
"value": string
}
"LoopsApiKey": {
"type": "sst.sst.Secret"
"value": string
}
"NestriGPUCluster": {
"type": "aws.ecs/cluster.Cluster"
"value": string
}
"NestriGPUTask": {
"type": "aws.ecs/taskDefinition.TaskDefinition"
"value": string
}
"Urls": {
"api": string
"auth": string
"site": string
"type": "sst.sst.Linkable"
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"Auth": cloudflare.Service
"CloudflareAuthKV": cloudflare.KVNamespace
"Web": {
"type": "sst.aws.StaticSite"
"url": string
}
}
}

16
packages/scripts/src/psql.ts Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bun
import { Resource } from "sst";
import { spawnSync } from "bun";
spawnSync(
[
"psql",
`postgresql://${Resource.Database.user}:${Resource.Database.password}@${Resource.Database.host}/${Resource.Database.name}?sslmode=require`,
],
{
stdout: "inherit",
stdin: "inherit",
stderr: "inherit",
},
);

View File

@@ -5,7 +5,7 @@ module.exports = {
es2021: true,
node: true,
},
extends: [
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:qwik/recommended",

175
packages/www/.gitignore vendored Normal file
View File

@@ -0,0 +1,175 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# 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
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
# 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.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
packages/www/README.md Normal file
View File

@@ -0,0 +1,15 @@
# @console/www
To install dependencies:
```bash
bun install
```
To run:
```bash
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.

43
packages/www/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content="hsla(0,0%,98%)"
/>
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content="hsla(0,0%,0%)"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/src/assets/seo/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/src/assets/seo/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/src/assets/seo/favicon-16x16.png"
/>
<title>Nestri - Your games. Your rules.</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

32
packages/www/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@nestri/www",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"typecheck": "tsc --noEmit --incremental"
},
"devDependencies": {
"@macaron-css/vite": "^1.5.1",
"@types/bun": "latest",
"vite": "5.4.10",
"vite-plugin-solid": "^2.11.2"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@fontsource-variable/geist-mono": "^5.0.1",
"@nestri/core": "*",
"@fontsource-variable/mona-sans": "^5.0.1",
"@fontsource/geist-sans": "^5.1.0",
"@macaron-css/core": "^1.5.2",
"@macaron-css/solid": "^1.5.3",
"@solid-primitives/storage": "^4.3.1",
"@solidjs/router": "^0.15.3",
"modern-normalize": "^3.0.1",
"solid-js": "^1.9.5"
}
}

147
packages/www/src/App.tsx Normal file
View File

@@ -0,0 +1,147 @@
import '@fontsource-variable/mona-sans';
import '@fontsource-variable/geist-mono';
import '@fontsource/geist-sans/400.css';
import '@fontsource/geist-sans/500.css';
import '@fontsource/geist-sans/600.css';
import '@fontsource/geist-sans/700.css';
import '@fontsource/geist-sans/800.css';
import '@fontsource/geist-sans/900.css';
import { TeamCreate } from './pages/new';
import { styled } from "@macaron-css/solid";
import { useStorage } from './providers/account';
import { darkClass, lightClass, theme } from './ui/theme';
import { AuthProvider, useAuth } from './providers/auth';
import { Navigate, Route, Router } from "@solidjs/router";
import { globalStyle, macaron$ } from "@macaron-css/core";
import { Component, createSignal, Match, onCleanup, Switch } from 'solid-js';
const Root = styled("div", {
base: {
inset: 0,
lineHeight: 1,
fontSynthesis: "none",
color: theme.color.d1000.gray,
fontFamily: theme.font.family.body,
textRendering: "optimizeLegibility",
WebkitFontSmoothing: "antialised",
backgroundColor: theme.color.background.d100,
},
});
globalStyle("html", {
fontSize: 16,
fontWeight: 400,
// Hardcode colors
"@media": {
"(prefers-color-scheme: light)": {
backgroundColor: "hsla(0,0%,98%)",
},
"(prefers-color-scheme: dark)": {
backgroundColor: "hsla(0,0%,0%)",
},
},
});
globalStyle("h1, h2, h3, h4, h5, h6, p", {
margin: 0,
});
macaron$(() =>
["::placeholder", ":-ms-input-placeholder"].forEach((selector) =>
globalStyle(selector, {
opacity: 1,
color: theme.color.d1000.gray,
}),
),
);
globalStyle("body", {
cursor: "default",
});
globalStyle("*", {
boxSizing: "border-box",
});
export const App: Component = () => {
const [theme, setTheme] = createSignal<string>(
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light",
);
const darkMode = window.matchMedia("(prefers-color-scheme: dark)");
const setColorScheme = (e: MediaQueryListEvent) => {
setTheme(e.matches ? "dark" : "light");
};
darkMode.addEventListener("change", setColorScheme);
onCleanup(() => {
darkMode.removeEventListener("change", setColorScheme);
});
const storage = useStorage();
return (
<Root class={theme() === "light" ? lightClass : darkClass} id="styled">
<Router>
<Route
path="*"
component={(props) => (
<AuthProvider>
{props.children}
</AuthProvider>
// <CommandBar>
// <ReplicacheStatusProvider>
// <DummyProvider>
// <DummyConfigProvider>
// <FlagsProvider>
// <RealtimeProvider />
// <LocalProvider>
// <LocalLogsProvider>
// <GlobalCommands />
// {props.children}
// </LocalLogsProvider>
// </LocalProvider>
// </FlagsProvider>
// </DummyConfigProvider>
// </DummyProvider>
// </ReplicacheStatusProvider>
// </AuthProvider>
// </CommandBar>
)}
>
{/* <Route path="local" component={Local} />
<Route path="debug" component={DebugRoute} />
<Route path="design" component={Design} />
<Route path="workspace" component={WorkspaceCreate} />
<Route path=":workspaceSlug">{WorkspaceRoute}</Route> */}
<Route path="new" component={TeamCreate} />
<Route
path="/"
component={() => {
const auth = useAuth();
return (
<Switch>
<Match when={auth.current.teams.length > 0}>
<Navigate
href={`/${(
auth.current.teams.find(
(w) => w.id === storage.value.team,
) || auth.current.teams[0]
).slug
}`}
/>
</Match>
<Match when={true}>
<Navigate href={`/new`} />
</Match>
</Switch>
);
}}
/>
{/* <Route path="*" component={() => <NotFound />} /> */}
</Route>
</Router>
</Root>
)
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/icons/mstile-150x150.png"/>
<TileColor>#ffede5</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M398 4232 l-48 -3 -1 -42 c-2 -188 2 -861 6 -865 2 -3 997 -5 2210
-6 l2205 -1 -2 458 -3 459 -2160 1 c-1188 1 -2181 1 -2207 -1z"/>
<path d="M350 2984 c0 -19 0 -198 0 -399 0 -201 0 -391 0 -424 l0 -58 2210 0
2210 0 0 457 0 457 -2210 0 -2210 0 0 -33z"/>
<path d="M354 1799 c-3 -6 -7 -613 -5 -844 l1 -70 2197 2 c1209 1 2204 2 2211
3 18 0 18 910 0 911 -81 4 -4401 2 -4404 -2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 902 B

View File

@@ -0,0 +1,18 @@
{
"name": "Nestri",
"short_name": "Nestri",
"icons": [
{
"src": "/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#fafafa",
"background_color": "#fafafa",
"display": "standalone"}

View File

@@ -0,0 +1,26 @@
import { ParentProps, Show, createContext, useContext } from "solid-js";
export function createInitializedContext<
Name extends string,
T extends { ready: boolean }
>(name: Name, cb: () => T) {
const ctx = createContext<T>();
return {
use: () => {
const context = useContext(ctx);
if (!context) throw new Error(`No ${name} context`);
return context;
},
provider: (props: ParentProps) => {
const value = cb();
return (
<Show when={value.ready}>
<ctx.Provider value={value} {...props}>
{props.children}
</ctx.Provider>
</Show>
);
},
}
}

View File

@@ -0,0 +1,27 @@
/* @refresh reload */
import { render } from "solid-js/web";
// import posthog from "posthog-js";
// posthog.init("phc_M0b2lW4smpsGIufiTBZ22USKwCy0fyqljMOGufJc79p", {
// api_host: "https://telemetry.ion.sst.dev",
// });
import "modern-normalize/modern-normalize.css";
import { App } from "./App";
import { StorageProvider } from "./providers/account";
const root = document.getElementById("root");
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got mispelled?"
);
}
render(
() => (
<StorageProvider>
<App />
</StorageProvider>
),
root!
);

View File

@@ -0,0 +1,7 @@
export function DefaultState() {
return (
<div>
We are logging you in
</div>
)
}

View File

@@ -0,0 +1,15 @@
import { Container, FullScreen } from "@nestri/www/ui/layout";
import { Text } from "@nestri/www/ui/text";
export function TeamCreate() {
return (
<FullScreen>
<Container flow="column" >
<Text align="center" spacing="lg" size="4xl" weight="semibold">
Your first deploy is just a sign-up away.
</Text>
</Container>
</FullScreen>
)
}

View File

@@ -0,0 +1,34 @@
import { createStore } from "solid-js/store";
import { makePersisted } from "@solid-primitives/storage";
import { ParentProps, createContext, useContext } from "solid-js";
type Context = ReturnType<typeof init>;
const context = createContext<Context>();
function init() {
const [store, setStore] = makePersisted(
createStore({
account: "",
team: "",
dummy: "",
})
);
return {
value: store,
set: setStore,
};
}
export function StorageProvider(props: ParentProps) {
const ctx = init();
return <context.Provider value={ctx}>{props.children}</context.Provider>;
}
export function useStorage() {
const ctx = useContext(context);
if (!ctx) {
throw new Error("No storage context");
}
return ctx;
}

View File

@@ -0,0 +1,222 @@
import { type Team } from "@nestri/core/team/index";
import { makePersisted } from "@solid-primitives/storage";
import { useLocation, useNavigate } from "@solidjs/router";
import { createClient } from "@openauthjs/openauth/client";
import { createInitializedContext } from "../common/context";
import { createEffect, createMemo, onMount } from "solid-js";
import { createStore, produce, reconcile } from "solid-js/store";
interface AccountInfo {
id: string;
email: string;
name: string;
access: string;
refresh: string;
avatarUrl: string;
teams: Team.Info[];
discriminator: number;
polarCustomerID: string | null;
}
interface Storage {
accounts: Record<string, AccountInfo>;
current?: string;
}
export const client = createClient({
issuer: import.meta.env.VITE_AUTH_URL,
clientID: "web",
});
export const { use: useAuth, provider: AuthProvider } =
createInitializedContext("AuthContext", () => {
const [store, setStore] = makePersisted(
createStore<Storage>({
accounts: {},
}),
{
name: "radiant.auth",
},
);
const location = useLocation();
const params = createMemo(
() => new URLSearchParams(location.hash.substring(1)),
);
const accessToken = createMemo(() => params().get("access_token"));
const refreshToken = createMemo(() => params().get("refresh_token"));
createEffect(async () => {
// if (!result.current && Object.keys(store.accounts).length) {
// result.switch(Object.keys(store.accounts)[0])
// navigate("/")
// }
})
createEffect(async () => {
if (accessToken()) return;
if (Object.keys(store.accounts).length) return;
const redirect = await client.authorize(window.location.origin, "token");
window.location.href = redirect.url
});
createEffect(async () => {
const current = store.current;
const accounts = store.accounts;
if (!current) return;
const match = accounts[current];
if (match) return;
const keys = Object.keys(accounts);
if (keys.length) {
setStore("current", keys[0]);
navigate("/");
return
}
const redirect = await client.authorize(window.location.origin, "token");
window.location.href = redirect.url
});
async function refresh() {
for (const account of [...Object.values(store.accounts)]) {
if (!account.refresh) continue;
const result = await client.refresh(account.refresh, {
access: account.access,
})
if (result.err) {
if ("id" in account)
setStore(produce((state) => {
delete state.accounts[account.id];
}))
continue
};
const tokens = result.tokens || {
access: account.access,
refresh: account.refresh,
}
fetch(import.meta.env.VITE_API_URL + "/account", {
headers: {
authorization: `Bearer ${tokens.access}`,
},
}).then(async (response) => {
await new Promise((resolve) => setTimeout(resolve, 5000));
if (response.ok) {
const result = await response.json();
const info = await result.data;
setStore(
"accounts",
info.id,
reconcile({
...info,
...tokens,
}),
);
}
if (!response.ok)
setStore(
produce((state) => {
delete state.accounts[account.id];
}),
);
})
}
}
onMount(async () => {
if (refreshToken() && accessToken()) {
const result = await fetch(import.meta.env.VITE_API_URL + "/account", {
headers: {
authorization: `Bearer ${accessToken()}`,
},
}).catch(() => { })
if (result?.ok) {
const response = await result.json();
const info = await response.data;
setStore(
"accounts",
info.id,
reconcile({
...info,
access: accessToken(),
refresh: refreshToken(),
}),
);
setStore("current", info.id);
}
window.location.hash = "";
}
await refresh();
})
const navigate = useNavigate();
// const bar = useCommandBar()
// bar.register("auth", async () => {
// return [
// {
// category: "Account",
// title: "Logout",
// icon: IconLogout,
// run: async (bar) => {
// result.logout();
// setStore("current", undefined);
// navigate("/");
// bar.hide()
// },
// },
// {
// category: "Add Account",
// title: "Add Account",
// icon: IconUserAdd,
// run: async () => {
// const redir = await client.authorize(window.location.origin, "token");
// window.location.href = redir.url
// bar.hide()
// },
// },
// ...result.all()
// .filter((item) => item.id !== result.current.id)
// .map((item) => ({
// category: "Account",
// title: "Switch to " + item.email,
// icon: IconUser,
// run: async () => {
// result.switch(item.id);
// navigate("/");
// bar.hide()
// },
// })),
// ]
// })
const result = {
get current() {
return store.accounts[store.current!]!;
},
switch(accountID: string) {
setStore("current", accountID);
},
all() {
return Object.values(store.accounts);
},
refresh,
logout() {
setStore(
produce((state) => {
if (!state.current) return;
delete state.accounts[state.current];
state.current = Object.keys(state.accounts)[0];
}),
);
},
get ready() {
return Boolean(!accessToken() && store.current);
},
};
return result;
});

12
packages/www/src/sst-env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_AUTH_URL: string
readonly VITE_STAGE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,48 @@
import { theme } from "./theme";
import { styled } from "@macaron-css/solid";
export const FullScreen = styled("div", {
base: {
inset: 0,
zIndex: 0,
display: "flex",
position: "fixed",
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.color.background.d200,
},
variants: {
inset: {
none: {},
header: {
top: theme.headerHeight.root,
},
},
},
})
export const Container = styled("div", {
base: {
backgroundColor: theme.color.background.d100,
borderColor: theme.color.gray.d400,
padding: "64px 80px 48px",
justifyContent: "center",
borderStyle: "solid",
position: "relative",
borderRadius: 12,
alignItems: "center",
maxWidth: 550,
borderWidth: 1,
display: "flex",
},
variants: {
flow: {
column: {
flexDirection: "column"
},
row: {
flexDirection: "row"
}
}
}
})

View File

@@ -0,0 +1,167 @@
import { theme } from "./theme";
import { styled } from "@macaron-css/solid";
import { utility } from "./utility";
import { CSSProperties } from "@macaron-css/core";
export const Text = styled("span", {
base: {
textWrap: "balance"
},
variants: {
leading: {
base: {
lineHeight: 1,
},
normal: {
lineHeight: "normal",
},
loose: {
lineHeight: theme.font.lineHeight,
},
},
align: {
left: {
textAlign: "left"
},
center: {
textAlign: "center"
}
},
spacing: {
none: {
letterSpacing: 0
},
xs: {
letterSpacing: -0.96
},
sm: {
letterSpacing: -0.96
},
md: {
letterSpacing: -1.28
},
lg: {
letterSpacing: -1.28
}
},
code: {
true: {
fontFamily: theme.font.family.code,
},
},
capitalize: {
true: {
textTransform: "capitalize",
},
},
uppercase: {
true: {
letterSpacing: 0.5,
textTransform: "uppercase",
},
},
weight: {
regular: {
fontWeight: theme.font.weight.regular,
},
medium: {
fontWeight: theme.font.weight.medium,
},
semibold: {
fontWeight: theme.font.weight.semibold,
},
},
center: {
true: {
textAlign: "center",
},
},
line: {
true: {
...utility.text.line,
},
},
disableSelect: {
true: {
userSelect: "none",
WebkitUserSelect: "none",
},
},
pre: {
true: {
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
},
},
underline: {
true: {
textUnderlineOffset: 2,
textDecoration: "underline",
},
},
label: {
true: {
fontWeight: 500,
letterSpacing: 0.5,
textTransform: "uppercase",
fontFamily: theme.font.family.code,
},
},
break: {
true: {
wordBreak: "break-all",
},
false: {},
},
size: (() => {
const result = {} as Record<`${keyof typeof theme.font.size}`, any>;
for (const [key, value] of Object.entries(theme.font.size)) {
result[key as keyof typeof theme.font.size] = {
fontSize: value,
};
}
return result;
})(),
color: (() => {
const record = {} as Record<keyof typeof theme.color.text, CSSProperties>;
for (const [key, _value] of Object.entries(theme.color.text)) {
record[key as keyof typeof record] = {};
}
return record;
})(),
on: (() => {
const record = {} as Record<
keyof typeof theme.color.text.primary,
CSSProperties
>;
for (const [key, _value] of Object.entries(theme.color.text.primary)) {
record[key as keyof typeof record] = {};
}
return record;
})(),
},
compoundVariants: (() => {
const result: any[] = [];
for (const [color, ons] of Object.entries(theme.color.text)) {
for (const [on, value] of Object.entries(ons)) {
result.push({
variants: {
color,
on,
},
style: {
color: value,
},
});
}
}
return result;
})(),
defaultVariants: {
on: "base",
size: "base",
color: "primary",
spacing: "none",
weight: "regular",
},
});

Some files were not shown because too many files have changed in this diff Show More