mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-11 00:05:36 +02:00
⭐feat: Add more API endpoints (#150)
This commit is contained in:
@@ -40,6 +40,10 @@ export const auth = new sst.cloudflare.Worker("Auth", {
|
||||
export const api = new sst.cloudflare.Worker("Api", {
|
||||
link: [
|
||||
urls,
|
||||
authFingerprintKey,
|
||||
secret.InstantAdminToken,
|
||||
secret.InstantAppId,
|
||||
secret.LoopsApiKey
|
||||
],
|
||||
url: true,
|
||||
handler: "./packages/functions/src/api/index.ts",
|
||||
|
||||
@@ -19,17 +19,12 @@ const rules = {
|
||||
* bind: ["isOwner", "auth.id != null && auth.id == data.ownerId"],
|
||||
* },
|
||||
*/
|
||||
"$default": {
|
||||
"allow": {
|
||||
"$default": "false"
|
||||
}
|
||||
},
|
||||
machines: {
|
||||
allow: {
|
||||
"$default": "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;
|
||||
|
||||
@@ -1,74 +1,53 @@
|
||||
import { i } from "@instantdb/core";
|
||||
|
||||
const _schema = i.schema({
|
||||
// This section lets you define entities: think `posts`, `comments`, etc
|
||||
// Take a look at the docs to learn more:
|
||||
// https://www.instantdb.com/docs/modeling-data#2-attributes
|
||||
entities: {
|
||||
$users: i.entity({
|
||||
email: i.string().unique().indexed(),
|
||||
}),
|
||||
// This is here because the $users entity has no more than 1 property; email
|
||||
// profiles: i.entity({
|
||||
// name: i.string(),
|
||||
// location: i.string(),
|
||||
// createdAt: i.date(),
|
||||
// deletedAt: i.date().optional()
|
||||
// }),
|
||||
machines: i.entity({
|
||||
hostname: i.string(),
|
||||
location: i.string(),
|
||||
fingerprint: i.string().indexed(),
|
||||
createdAt: i.date(),
|
||||
deletedAt: i.date().optional().indexed()
|
||||
fingerprint: i.string().unique().indexed(),
|
||||
deletedAt: i.date().optional().indexed(),
|
||||
createdAt: i.date()
|
||||
}),
|
||||
games: i.entity({
|
||||
name: i.string(),
|
||||
steamID: i.number().unique().indexed(),
|
||||
}),
|
||||
sessions: i.entity({
|
||||
name: i.string(),
|
||||
startedAt: i.date(),
|
||||
endedAt: i.date().optional().indexed(),
|
||||
public: i.boolean().indexed(),
|
||||
}),
|
||||
// teams: i.entity({
|
||||
// name: i.string(),
|
||||
// type: i.string(), // "Personal" or "Family"
|
||||
// createdAt: i.date(),
|
||||
// deletedAt: i.date().optional()
|
||||
// }),
|
||||
// subscriptions: i.entity({
|
||||
// quantity: i.number(),
|
||||
// polarOrderID: i.string(),
|
||||
// frequency: i.string(),
|
||||
// next: i.date().optional(),
|
||||
// }),
|
||||
// productVariants: i.entity({
|
||||
// name: i.string(),
|
||||
// price: i.number()
|
||||
// })
|
||||
},
|
||||
// links: {
|
||||
// userProfiles: {
|
||||
// forward: { on: 'profiles', has: 'one', label: 'owner' },
|
||||
// reverse: { on: '$users', has: 'one', label: 'profile' },
|
||||
// },
|
||||
// machineOwners: {
|
||||
// forward: { on: 'machines', has: 'one', label: 'owner' },
|
||||
// reverse: { on: '$users', has: 'many', label: 'machinesOwned' },
|
||||
// },
|
||||
// machineTeams: {
|
||||
// forward: { on: 'machines', has: 'one', label: 'team' },
|
||||
// reverse: { on: 'teams', has: 'many', label: 'machines' },
|
||||
// },
|
||||
// userTeams: {
|
||||
// forward: { on: 'teams', has: 'one', label: 'owner' },
|
||||
// reverse: { on: '$users', has: 'many', label: 'teamsOwned' },
|
||||
// },
|
||||
// teamMembers: {
|
||||
// forward: { on: 'teams', has: 'many', label: 'members' },
|
||||
// reverse: { on: '$users', has: 'many', label: 'teams' },
|
||||
// },
|
||||
// subscribedProduct: {
|
||||
// forward: { on: "subscriptions", has: "one", label: "productVariant" },
|
||||
// reverse: { on: "productVariants", has: "many", label: "subscriptions" }
|
||||
// },
|
||||
// subscribedUser: {
|
||||
// forward: { on: "subscriptions", has: "one", label: "owner" },
|
||||
// reverse: { on: "$users", has: "many", label: "subscriptions" }
|
||||
// }
|
||||
// }
|
||||
links: {
|
||||
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" }
|
||||
},
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"loops": "^3.4.1",
|
||||
"remeda": "^2.19.0",
|
||||
"ulid": "^2.3.0",
|
||||
"uuid": "^11.0.3",
|
||||
"zod": "^3.24.1",
|
||||
|
||||
@@ -49,6 +49,7 @@ export function useCurrentUser() {
|
||||
id:actor.properties.userID,
|
||||
token: actor.properties.accessToken
|
||||
};
|
||||
|
||||
throw new VisibleError(
|
||||
"auth",
|
||||
"unauthorized",
|
||||
|
||||
@@ -3,10 +3,10 @@ import { init } from "@instantdb/admin";
|
||||
import schema from "../instant.schema";
|
||||
|
||||
const databaseClient = () => init({
|
||||
appId: Resource.InstantAppId.value,
|
||||
adminToken: Resource.InstantAdminToken.value,
|
||||
schema
|
||||
})
|
||||
appId: Resource.InstantAppId.value,
|
||||
adminToken: Resource.InstantAdminToken.value,
|
||||
schema
|
||||
})
|
||||
|
||||
|
||||
export default databaseClient
|
||||
@@ -7,39 +7,23 @@ export module Examples {
|
||||
|
||||
export const Machine = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
hostname: "desktopeuo8vsf",
|
||||
hostname: "DESKTOP-EUO8VSF",
|
||||
fingerprint: "fc27f428f9ca47d4b41b70889ae0c62090",
|
||||
location: "KE, AF"
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
deletedAt: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
// export const Team = {
|
||||
// id: createID(),
|
||||
// name: "Jane's Family",
|
||||
// type: "Family"
|
||||
// }
|
||||
|
||||
// export const ProductVariant = {
|
||||
// id: createID(),
|
||||
// name: "FamilySM",
|
||||
// price: 10,
|
||||
// };
|
||||
|
||||
// export const Product = {
|
||||
// id: createID(),
|
||||
// name: "Family",
|
||||
// description: "The ideal subscription tier for dedicated gamers who crave more flexibility and social gaming experiences.",
|
||||
// variants: [ProductVariant],
|
||||
// subscription: "allowed" as const,
|
||||
// };
|
||||
|
||||
// export const Subscription = {
|
||||
// id: createID(),
|
||||
// productVariant: ProductVariant,
|
||||
// quantity: 1,
|
||||
// polarOrderID: createID(),
|
||||
// frequency: "monthly" as const,
|
||||
// next: new Date("2024-02-01 19:36:19.000").getTime(),
|
||||
// owner: User
|
||||
// };
|
||||
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,
|
||||
name: 'Late night chilling with the squad',
|
||||
startedAt: '2025-01-04T11:56:23.902Z',
|
||||
endedAt: '2025-01-04T11:56:23.902Z'
|
||||
}
|
||||
}
|
||||
151
packages/core/src/game/index.ts
Normal file
151
packages/core/src/game/index.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { useCurrentDevice, useCurrentUser } from "../actor";
|
||||
|
||||
export module Games {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Game.id,
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "A human-readable name for the game, used for easy identification.",
|
||||
example: Examples.Game.name,
|
||||
}),
|
||||
steamID: z.number().openapi({
|
||||
description: "The Steam ID of the game, used to identify it during installation and runtime.",
|
||||
example: Examples.Game.steamID,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Game",
|
||||
description: "Represents a Steam game that can be installed and played on a machine.",
|
||||
example: Examples.Game,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(Info.pick({ name: true, steamID: true }), async (input) => {
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const device = useCurrentDevice()
|
||||
|
||||
await db.transact(
|
||||
db.tx.games[id]!.update({
|
||||
name: input.name,
|
||||
steamID: input.steamID,
|
||||
}).link({ machines: device.id })
|
||||
)
|
||||
|
||||
return id
|
||||
})
|
||||
|
||||
export const list = async () => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
games: {}
|
||||
},
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const games = res.$users[0]?.games
|
||||
if (games && games.length > 0) {
|
||||
const result = pipe(
|
||||
games,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
steamID: group[0].steamID,
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const fromSteamID = fn(z.number(), async (steamID) => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
games: {
|
||||
$: {
|
||||
where: {
|
||||
steamID,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const games = res.games
|
||||
|
||||
if (games.length > 0) {
|
||||
const result = pipe(
|
||||
games,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
steamID: group[0].steamID,
|
||||
}))
|
||||
)
|
||||
return result[0]
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
export const linkToCurrentUser = fn(z.string(), async (steamID) => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
|
||||
await db.transact(db.tx.games[steamID]!.link({ owners: user.id }))
|
||||
|
||||
return "ok"
|
||||
})
|
||||
|
||||
export const unLinkFromCurrentUser = fn(z.number(), async (steamID) => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
games: {
|
||||
$: {
|
||||
where: {
|
||||
steamID,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const games = res.$users[0]?.games
|
||||
if (games && games.length > 0) {
|
||||
const game = games[0] as Info
|
||||
await db.transact(db.tx.games[game.id]!.unlink({ owners: user.id }))
|
||||
|
||||
return "ok"
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
import { useCurrentUser } from "../actor";
|
||||
import databaseClient from "../database"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
|
||||
export module Machine {
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { Games } from "../game"
|
||||
export module Machines {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
@@ -14,63 +15,45 @@ export module Machine {
|
||||
example: Examples.Machine.id,
|
||||
}),
|
||||
hostname: z.string().openapi({
|
||||
description: "Hostname of the machine",
|
||||
description: "The Linux hostname that identifies this machine",
|
||||
example: Examples.Machine.hostname,
|
||||
}),
|
||||
fingerprint: z.string().openapi({
|
||||
description: "The machine's fingerprint, derived from the machine's Linux machine ID.",
|
||||
description: "A unique identifier derived from the machine's Linux machine ID.",
|
||||
example: Examples.Machine.fingerprint,
|
||||
}),
|
||||
location: z.string().openapi({
|
||||
description: "The machine's approximate location; country and continent.",
|
||||
example: Examples.Machine.location,
|
||||
createdAt: z.string().or(z.number()).openapi({
|
||||
description: "Represents a machine running on the Nestri network, containing its identifying information and metadata.",
|
||||
example: Examples.Machine.createdAt,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Machine",
|
||||
description: "A machine running on the Nestri network.",
|
||||
description: "Represents a a physical or virtual machine connected to the Nestri network..",
|
||||
example: Examples.Machine,
|
||||
});
|
||||
|
||||
export const create = fn(z.object({
|
||||
fingerprint: z.string(),
|
||||
hostname: z.string(),
|
||||
location: z.string()
|
||||
}), async (input) => {
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(Info.pick({ fingerprint: true, hostname: true }), async (input) => {
|
||||
const id = createID()
|
||||
const now = new Date().getTime()
|
||||
const now = new Date().toISOString()
|
||||
const db = databaseClient()
|
||||
await db.transact(
|
||||
db.tx.machines[id]!.update({
|
||||
fingerprint: input.fingerprint,
|
||||
hostname: input.hostname,
|
||||
location: input.location,
|
||||
createdAt: now,
|
||||
//Just in case it had been previously deleted
|
||||
deletedAt: undefined
|
||||
})
|
||||
)
|
||||
|
||||
return id
|
||||
})
|
||||
|
||||
export const remove = fn(z.string(), async (id) => {
|
||||
const now = new Date().getTime()
|
||||
// const device = useCurrentDevice()
|
||||
// const db = databaseClient()
|
||||
|
||||
// if (device.id) { // the machine can delete itself
|
||||
// await db.transact(db.tx.machines[device.id]!.update({ deletedAt: now }))
|
||||
// } else {// the user can delete it manually
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient().asUser({ token: user.token })
|
||||
await db.transact(db.tx.machines[id]!.update({ deletedAt: now }))
|
||||
// }
|
||||
|
||||
return "ok"
|
||||
})
|
||||
|
||||
export const fromID = fn(z.string(), async (id) => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient().asUser({ token: user.token })
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
machines: {
|
||||
@@ -84,8 +67,53 @@ export module Machine {
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const machines = res.machines
|
||||
|
||||
return res.machines[0]
|
||||
if (machines && machines.length > 0) {
|
||||
const result = pipe(
|
||||
machines,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
fingerprint: group[0].fingerprint,
|
||||
hostname: group[0].hostname,
|
||||
createdAt: group[0].createdAt
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
export const installedGames = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
machines: {
|
||||
$: {
|
||||
where: {
|
||||
id: id,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
games: {}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const machines = res.machines
|
||||
|
||||
if (machines && machines.length > 0) {
|
||||
const games = machines[0]?.games as any
|
||||
if (games.length > 0) {
|
||||
return games as Games.Info[]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
export const fromFingerprint = fn(z.string(), async (input) => {
|
||||
@@ -104,19 +132,38 @@ export module Machine {
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
return res.machines[0]
|
||||
const machines = res.machines
|
||||
|
||||
if (machines.length > 0) {
|
||||
const result = pipe(
|
||||
machines,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
fingerprint: group[0].fingerprint,
|
||||
hostname: group[0].hostname,
|
||||
createdAt: group[0].createdAt
|
||||
}))
|
||||
)
|
||||
return result[0]
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
export const list = async () => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient().asUser({ token: user.token })
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
machines: {
|
||||
$: {
|
||||
deletedAt: { $isNull: true }
|
||||
where: {
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -124,17 +171,62 @@ export module Machine {
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
return res.$users[0]?.machines
|
||||
const machines = res.$users[0]?.machines
|
||||
if (machines && machines.length > 0) {
|
||||
const result = pipe(
|
||||
machines,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
fingerprint: group[0].fingerprint,
|
||||
hostname: group[0].hostname,
|
||||
createdAt: group[0].createdAt
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const link = fn(z.object({
|
||||
machineId: z.string()
|
||||
}), async (input) => {
|
||||
export const linkToCurrentUser = fn(z.string(), async (id) => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
|
||||
await db.transact(db.tx.machines[input.machineId]!.link({ owner: user.id }))
|
||||
await db.transact(db.tx.machines[id]!.link({ owner: user.id }))
|
||||
|
||||
return "ok"
|
||||
})
|
||||
|
||||
export const unLinkFromCurrentUser = fn(z.string(), async (id) => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
machines: {
|
||||
$: {
|
||||
where: {
|
||||
id,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const machines = res.$users[0]?.machines
|
||||
if (machines && machines.length > 0) {
|
||||
const machine = machines[0] as Info
|
||||
await db.transact(db.tx.machines[machine.id]!.update({ deletedAt: now }))
|
||||
|
||||
return "ok"
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
}
|
||||
292
packages/core/src/session/index.ts
Normal file
292
packages/core/src/session/index.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Machines } from "../machine";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
import { useCurrentUser } from "../actor";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
|
||||
export module Sessions {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Session.id,
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "A human-readable name for the session to help identify it",
|
||||
example: Examples.Session.name,
|
||||
}),
|
||||
public: z.boolean().openapi({
|
||||
description: "If true, the session is publicly viewable by all users. If false, only authorized users can access it",
|
||||
example: Examples.Session.public,
|
||||
}),
|
||||
endedAt: z.string().or(z.number()).or(z.undefined()).openapi({
|
||||
description: "The timestamp indicating when this session was completed or terminated. Null if session is still active.",
|
||||
example: Examples.Session.endedAt,
|
||||
}),
|
||||
startedAt: z.string().or(z.number()).openapi({
|
||||
description: "The timestamp indicating when this session started.",
|
||||
example: Examples.Session.startedAt,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Session",
|
||||
description: "Represents a single game play session, tracking its lifetime and accessibility settings.",
|
||||
example: Examples.Session,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(z.object({ name: z.string(), public: z.boolean(), fingerprint: z.string(), steamID: z.number() }), async (input) => {
|
||||
const id = createID()
|
||||
const now = new Date().toISOString()
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const machine = await Machines.fromFingerprint(input.fingerprint)
|
||||
if (!machine) {
|
||||
return { error: "Such a machine does not exist" }
|
||||
}
|
||||
|
||||
const games = await Machines.installedGames(machine.id)
|
||||
|
||||
if (!games) {
|
||||
return { error: "The machine has no installed games" }
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
games,
|
||||
groupBy(x => x.steamID === input.steamID ? "similar" : undefined),
|
||||
)
|
||||
|
||||
if (!result.similar || result.similar.length == 0) {
|
||||
|
||||
return { error: "The machine does not have this game installed" }
|
||||
}
|
||||
|
||||
await db.transact(
|
||||
db.tx.sessions[id]!.update({
|
||||
name: input.name,
|
||||
public: input.public,
|
||||
startedAt: now,
|
||||
}).link({ owner: user.id, machine: machine.id, game: result.similar[0].id })
|
||||
)
|
||||
|
||||
return { data: id }
|
||||
})
|
||||
|
||||
export const list = async () => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
sessions: {}
|
||||
},
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const sessions = res.$users[0]?.sessions
|
||||
if (sessions && sessions.length > 0) {
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
name: group[0].name
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const getActive = async () => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
endedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const sessions = res.$users[0]?.sessions
|
||||
if (sessions && sessions.length > 0) {
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
name: group[0].name
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const getPublicActive = async () => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
endedAt: { $isNull: true },
|
||||
public: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const sessions = res.sessions
|
||||
if (sessions && sessions.length > 0) {
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
name: group[0].name
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const fromSteamID = fn(z.number(), async (steamID) => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
games: {
|
||||
$: {
|
||||
where: {
|
||||
steamID
|
||||
}
|
||||
},
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
endedAt: { $isNull: true },
|
||||
public: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const sessions = res.games[0]?.sessions
|
||||
if (sessions && sessions.length > 0) {
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
name: group[0].name
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
export const fromID = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
useCurrentUser()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
id: id,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
|
||||
if (sessions && sessions.length > 0) {
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
name: group[0].name
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
export const end = fn(z.string(), async (id) => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
id,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.$users[0]?.sessions
|
||||
if (sessions && sessions.length > 0) {
|
||||
const session = sessions[0] as Info
|
||||
await db.transact(db.tx.sessions[session.id]!.update({ endedAt: now }))
|
||||
|
||||
return "ok"
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import databaseClient from "../database"
|
||||
import { z } from "zod"
|
||||
import { Common } from "../common";
|
||||
import { createID, fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
|
||||
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 machine",
|
||||
example: Examples.Team.name,
|
||||
}),
|
||||
type: z.string().nullable().openapi({
|
||||
description: "Whether this is a personal or family type of team",
|
||||
example: Examples.Team.type,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Team",
|
||||
description: "A group of Nestri user's who share the same machine",
|
||||
example: Examples.Team,
|
||||
});
|
||||
|
||||
export const create = fn(z.object({
|
||||
name: z.string(),
|
||||
type: z.enum(["personal", "family"]),
|
||||
owner: z.string(),
|
||||
}), async (input) => {
|
||||
const id = createID("machine")
|
||||
const now = new Date().getTime()
|
||||
const db = databaseClient()
|
||||
|
||||
await db.transact(db.tx.teams[id]!.update({
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
createdAt: now
|
||||
}).link({
|
||||
owner: input.owner,
|
||||
}))
|
||||
|
||||
return id
|
||||
})
|
||||
}
|
||||
264
packages/functions/src/api/game.ts
Normal file
264
packages/functions/src/api/game.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Resource } from "sst";
|
||||
import { ZodError } from "zod";
|
||||
import { GameApi } from "./game";
|
||||
import { logger } from "hono/logger";
|
||||
import { subjects } from "../subjects";
|
||||
import { VisibleError } from "../error";
|
||||
import { SessionApi } from "./session";
|
||||
import { MachineApi } from "./machine";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
import { VisibleError } from "@nestri/core/error";
|
||||
import { ActorContext } from '@nestri/core/actor';
|
||||
import { Hono, type MiddlewareHandler } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
@@ -79,10 +81,11 @@ app
|
||||
.use(auth);
|
||||
|
||||
const routes = app
|
||||
.get("/", (c) => c.text("Hello there 👋🏾"))
|
||||
.route("/machine", MachineApi.route)
|
||||
.route("/games", GameApi.route)
|
||||
.route("/machines", MachineApi.route)
|
||||
.route("/sessions", SessionApi.route)
|
||||
.onError((error, c) => {
|
||||
console.error(error);
|
||||
console.warn(error);
|
||||
if (error instanceof VisibleError) {
|
||||
return c.json(
|
||||
{
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
import { z } from "zod";
|
||||
import { Result } from "../common";
|
||||
import { Hono } from "hono";
|
||||
import { Result } from "../common";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Machine } from "@nestri/core/machine/index";
|
||||
import { useCurrentUser } from "@nestri/core/actor";
|
||||
|
||||
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: "List machines",
|
||||
description: "List the current user's machines.",
|
||||
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(
|
||||
Machine.Info.array().openapi({
|
||||
description: "List of machines.",
|
||||
Machines.Info.array().openapi({
|
||||
description: "A list of machines associated with the user",
|
||||
example: [Examples.Machine],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "List of machines.",
|
||||
description: "Successfully retrieved the list of machines",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
@@ -35,22 +34,22 @@ export module MachineApi {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "This user has no machines.",
|
||||
description: "No machines found for the authenticated user",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const machines = await Machine.list();
|
||||
if (!machines) return c.json({ error: "This user has no machines." }, 404);
|
||||
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(
|
||||
"/:id",
|
||||
"/:fingerprint",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Get machine",
|
||||
description: "Get the machine with the given ID.",
|
||||
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: {
|
||||
@@ -58,45 +57,45 @@ export module MachineApi {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "Machine not found.",
|
||||
description: "No machine found matching the provided fingerprint",
|
||||
},
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Machine.Info.openapi({
|
||||
description: "Machine.",
|
||||
Machines.Info.openapi({
|
||||
description: "Detailed information about the requested machine",
|
||||
example: Examples.Machine,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Machine.",
|
||||
description: "Successfully retrieved machine information",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the machine to get.",
|
||||
example: Examples.Machine.id,
|
||||
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 param = c.req.valid("param");
|
||||
const machine = await Machine.fromID(param.id);
|
||||
if (!machine) return c.json({ error: "Machine not found." }, 404);
|
||||
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(
|
||||
"/:id",
|
||||
"/:fingerprint",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Link a machine to a user",
|
||||
description: "Link a machine to the owner.",
|
||||
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: {
|
||||
@@ -104,33 +103,41 @@ export module MachineApi {
|
||||
schema: Result(z.literal("ok"))
|
||||
},
|
||||
},
|
||||
description: "Machine was linked successfully.",
|
||||
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({
|
||||
id: Machine.Info.shape.fingerprint.openapi({
|
||||
description: "Fingerprint of the machine to link to.",
|
||||
example: Examples.Machine.id,
|
||||
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 request = c.req.valid("param")
|
||||
const machine = await Machine.fromFingerprint(request.id)
|
||||
if (!machine) return c.json({ error: "Machine not found." }, 404);
|
||||
await Machine.link({machineId:machine.id })
|
||||
return c.json({ data: "ok" as const }, 200);
|
||||
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(
|
||||
"/:id",
|
||||
"/:fingerprint",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Delete machine",
|
||||
description: "Delete the machine with the given ID.",
|
||||
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: {
|
||||
@@ -138,23 +145,32 @@ export module MachineApi {
|
||||
schema: Result(z.literal("ok")),
|
||||
},
|
||||
},
|
||||
description: "Machine was deleted successfully.",
|
||||
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({
|
||||
id: Machine.Info.shape.id.openapi({
|
||||
description: "ID of the machine to delete.",
|
||||
example: Examples.Machine.id,
|
||||
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 param = c.req.valid("param");
|
||||
await Machine.remove(param.id);
|
||||
return c.json({ data: "ok" as const }, 200);
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
263
packages/functions/src/api/session.ts
Normal file
263
packages/functions/src/api/session.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
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";
|
||||
import { Machines } from "@nestri/core/machine/index";
|
||||
|
||||
export module SessionApi {
|
||||
export const route = new Hono()
|
||||
.get(
|
||||
"/",
|
||||
//FIXME: Add a way to filter through query params
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Retrieve all gaming sessions",
|
||||
description: "Returns a list of all gaming sessions associated with the authenticated user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Sessions.Info.array().openapi({
|
||||
description: "A list of gaming sessions associated with the user",
|
||||
example: [{ ...Examples.Session, public: false }],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the list of gaming sessions",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No gaming sessions found for the authenticated user",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const res = await Sessions.list();
|
||||
if (!res) return c.json({ error: "No gaming sessions found for this user" }, 404);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
)
|
||||
.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: false, 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(
|
||||
"/active/public",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Retrieve all publicly active gaming sessions",
|
||||
description: "Returns a list of all publicly active gaming sessions associated",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Sessions.Info.array().openapi({
|
||||
description: "A list of publicly active gaming sessions",
|
||||
example: [{ ...Examples.Session, public: true, endedAt: undefined }],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the list of all publicly active gaming sessions",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No publicly active gaming sessions found",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const res = await Sessions.getPublicActive();
|
||||
if (!res) return c.json({ error: "No publicly active gaming sessions found" }, 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(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Create a new gaming session for this user",
|
||||
description: "Creates 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
|
||||
}),
|
||||
steamID: Games.Info.shape.steamID.openapi({
|
||||
description: "The Steam ID of the game the user wants to play",
|
||||
example: Examples.Game.steamID
|
||||
}),
|
||||
fingerprint: Machines.Info.shape.fingerprint.openapi({
|
||||
description: "The unique fingerprint of the machine to play on, derived from its Linux machine ID",
|
||||
example: Examples.Machine.fingerprint
|
||||
}),
|
||||
name: Sessions.Info.shape.name.openapi({
|
||||
description: "The human readable name to give this session",
|
||||
example: Examples.Session.name
|
||||
})
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("json")
|
||||
//FIXME:
|
||||
const session = await Sessions.create(params)
|
||||
if (session.error) return c.json({ error: session.error }, 422);
|
||||
return c.json({ data: session.data }, 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",
|
||||
},
|
||||
}
|
||||
}),
|
||||
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 not found for this user" }, 404);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { PasswordUI } from "@openauthjs/openauth/ui/password"
|
||||
import type { Adapter } from "@openauthjs/openauth/adapter/adapter"
|
||||
import { PasswordAdapter } from "@openauthjs/openauth/adapter/password"
|
||||
import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
|
||||
import { Machine } from "@nestri/core/machine/index"
|
||||
import { Machines } from "@nestri/core/machine/index"
|
||||
|
||||
interface Env {
|
||||
CloudflareAuthKV: KVNamespace
|
||||
@@ -32,7 +32,7 @@ export type CodeAdapterState =
|
||||
|
||||
export default {
|
||||
async fetch(request: CFRequest, env: Env, ctx: ExecutionContext) {
|
||||
const location = `${request.cf.country},${request.cf.continent}`
|
||||
// const location = `${request.cf.country},${request.cf.continent}`
|
||||
return authorizer({
|
||||
select: Select({
|
||||
providers: {
|
||||
@@ -105,20 +105,24 @@ export default {
|
||||
},
|
||||
success: async (ctx, value) => {
|
||||
if (value.provider === "device") {
|
||||
let machineID = await Machine.fromFingerprint(value.fingerprint).then((x) => x?.id);
|
||||
|
||||
if (!machineID) {
|
||||
machineID = await Machine.create({
|
||||
let exists = await Machines.fromFingerprint(value.fingerprint);
|
||||
if (!exists) {
|
||||
const machineID = await Machines.create({
|
||||
fingerprint: value.fingerprint,
|
||||
hostname: value.hostname,
|
||||
location,
|
||||
});
|
||||
}
|
||||
|
||||
return await ctx.subject("device", {
|
||||
id: machineID,
|
||||
fingerprint: value.fingerprint
|
||||
})
|
||||
}
|
||||
|
||||
return await ctx.subject("device", {
|
||||
id: machineID,
|
||||
id: exists.id,
|
||||
fingerprint: value.fingerprint
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const email = value.email;
|
||||
|
||||
@@ -50,18 +50,19 @@ export module AuthApi {
|
||||
const env = c.env as any
|
||||
const room = env.room as Party.Room
|
||||
|
||||
const connection = room.getConnection(param.connection)
|
||||
if (!connection) {
|
||||
return c.json({ error: "This device does not exist." }, 404);
|
||||
}
|
||||
|
||||
const authParams = getUrlParams(new URL(c.req.url))
|
||||
const res = paramsObj.safeParse(authParams)
|
||||
if (res.error) {
|
||||
return c.json({ error: "Expected url params are missing" })
|
||||
}
|
||||
// const connection = room.getConnection(param.connection)
|
||||
// if (!connection) {
|
||||
// return c.json({ error: "This device does not exist." }, 404);
|
||||
// }
|
||||
|
||||
connection.send(JSON.stringify({ ...authParams, type: "auth" }))
|
||||
// const authParams = getUrlParams(new URL(c.req.url))
|
||||
// const res = paramsObj.safeParse(authParams)
|
||||
// if (res.error) {
|
||||
// return c.json({ error: "Expected url params are missing" })
|
||||
// }
|
||||
|
||||
// connection.send(JSON.stringify({ ...authParams, type: "auth" }))
|
||||
|
||||
// FIXME:We just assume the authentication was successful, might wanna do some questioning in the future
|
||||
return c.text("Device authenticated successfully")
|
||||
|
||||
Reference in New Issue
Block a user