From b47448255f2d95d703391b71287032fcc071c65c Mon Sep 17 00:00:00 2001 From: Wanjohi <71614375+wanjohiryan@users.noreply.github.com> Date: Sun, 5 Jan 2025 01:06:34 +0300 Subject: [PATCH] =?UTF-8?q?=E2=AD=90feat:=20Add=20more=20API=20endpoints?= =?UTF-8?q?=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lockb | Bin 740728 -> 741440 bytes infra/api.ts | 4 + packages/core/instant.perms.ts | 17 +- packages/core/instant.schema.ts | 99 ++++----- packages/core/package.json | 1 + packages/core/src/actor.ts | 1 + packages/core/src/database.ts | 8 +- packages/core/src/examples.ts | 46 ++-- packages/core/src/game/index.ts | 151 +++++++++++++ packages/core/src/machine/index.ts | 178 ++++++++++++---- packages/core/src/session/index.ts | 292 ++++++++++++++++++++++++++ packages/core/src/team/index.ts | 48 ----- packages/functions/src/api/game.ts | 264 +++++++++++++++++++++++ packages/functions/src/api/index.ts | 11 +- packages/functions/src/api/machine.ts | 114 +++++----- packages/functions/src/api/session.ts | 263 +++++++++++++++++++++++ packages/functions/src/auth.ts | 22 +- packages/functions/src/party/auth.ts | 21 +- 18 files changed, 1271 insertions(+), 269 deletions(-) create mode 100644 packages/core/src/game/index.ts create mode 100644 packages/core/src/session/index.ts delete mode 100644 packages/core/src/team/index.ts create mode 100644 packages/functions/src/api/game.ts create mode 100644 packages/functions/src/api/session.ts diff --git a/bun.lockb b/bun.lockb index 97a9fac091cfc016a5157dd36bf7363159be75c3..429e52710f8147cf7ece7c7ed49e95e8a920c42c 100755 GIT binary patch delta 18772 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 delta 18683 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 Bodo~@ diff --git a/infra/api.ts b/infra/api.ts index bd2eb3b4..5ff65d8a 100644 --- a/infra/api.ts +++ b/infra/api.ts @@ -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", diff --git a/packages/core/instant.perms.ts b/packages/core/instant.perms.ts index c43f0fa7..91d24755 100644 --- a/packages/core/instant.perms.ts +++ b/packages/core/instant.perms.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; diff --git a/packages/core/instant.schema.ts b/packages/core/instant.schema.ts index 87c6ad76..3c935003 100644 --- a/packages/core/instant.schema.ts +++ b/packages/core/instant.schema.ts @@ -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 diff --git a/packages/core/package.json b/packages/core/package.json index c58fe305..5ee442d9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/actor.ts b/packages/core/src/actor.ts index ef98e3d5..b3413fe9 100644 --- a/packages/core/src/actor.ts +++ b/packages/core/src/actor.ts @@ -49,6 +49,7 @@ export function useCurrentUser() { id:actor.properties.userID, token: actor.properties.accessToken }; + throw new VisibleError( "auth", "unauthorized", diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts index a1674a7d..c782219c 100644 --- a/packages/core/src/database.ts +++ b/packages/core/src/database.ts @@ -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 \ No newline at end of file diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 5a12a5aa..65b81a85 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -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' + } } \ No newline at end of file diff --git a/packages/core/src/game/index.ts b/packages/core/src/game/index.ts new file mode 100644 index 00000000..8c2d43ad --- /dev/null +++ b/packages/core/src/game/index.ts @@ -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; + + 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 + }) + +} \ No newline at end of file diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index 9fe4a8ca..01b0c8e1 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -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; + + 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 + }) + } \ No newline at end of file diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts new file mode 100644 index 00000000..edc2f6ac --- /dev/null +++ b/packages/core/src/session/index.ts @@ -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; + + 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 + }) +} \ No newline at end of file diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts deleted file mode 100644 index c2f56603..00000000 --- a/packages/core/src/team/index.ts +++ /dev/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 - }) -} \ No newline at end of file diff --git a/packages/functions/src/api/game.ts b/packages/functions/src/api/game.ts new file mode 100644 index 00000000..58ea7a50 --- /dev/null +++ b/packages/functions/src/api/game.ts @@ -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); + }, + ); +} \ No newline at end of file diff --git a/packages/functions/src/api/index.ts b/packages/functions/src/api/index.ts index 9a49ed6e..b1fd453d 100644 --- a/packages/functions/src/api/index.ts +++ b/packages/functions/src/api/index.ts @@ -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( { diff --git a/packages/functions/src/api/machine.ts b/packages/functions/src/api/machine.ts index 05e45711..9f6344d1 100644 --- a/packages/functions/src/api/machine.ts +++ b/packages/functions/src/api/machine.ts @@ -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); }, ); } \ No newline at end of file diff --git a/packages/functions/src/api/session.ts b/packages/functions/src/api/session.ts new file mode 100644 index 00000000..976db274 --- /dev/null +++ b/packages/functions/src/api/session.ts @@ -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); + }, + ); +} \ No newline at end of file diff --git a/packages/functions/src/auth.ts b/packages/functions/src/auth.ts index 48e2d018..670ba0ce 100644 --- a/packages/functions/src/auth.ts +++ b/packages/functions/src/auth.ts @@ -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; diff --git a/packages/functions/src/party/auth.ts b/packages/functions/src/party/auth.ts index c8ca3633..5ee0912c 100644 --- a/packages/functions/src/party/auth.ts +++ b/packages/functions/src/party/auth.ts @@ -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")