From c08a15f01f9e9cc39face3c983504f779ba1b009 Mon Sep 17 00:00:00 2001 From: Nolan Hellyer Date: Mon, 17 Feb 2025 15:21:03 -0800 Subject: [PATCH] add turns and update token logic. --- src/lib/GameData.ts | 8 +- src/lib/GameEvent.ts | 6 +- src/lib/Id.ts | 14 +-- src/lib/ServerResponse.ts | 2 + src/lib/components/PlayerList.svelte | 32 +++++++ src/lib/me.ts | 14 +++ src/lib/meContext.ts | 14 +++ src/lib/server/Game.ts | 15 ++- src/lib/server/ServerJwtPayload.ts | 15 +++ src/lib/server/auth.ts | 47 ++++++---- src/lib/server/getRequestBody.ts | 16 ---- src/lib/server/mongo.ts | 42 +++++++-- src/lib/server/requestTools.ts | 46 ++++++++++ src/lib/server/responseBodies.ts | 8 ++ src/lib/server/test/auth.spec.ts | 14 +++ src/lib/test/ServerResponse.spec.ts | 5 + src/routes/+layout.server.ts | 49 ++++++++++ src/routes/+layout.svelte | 22 +++++ src/routes/+page.svelte | 75 ++++++++++++++- src/routes/api/games/+server.ts | 17 +++- src/routes/api/games/[gameid]/+server.ts | 17 +--- .../api/games/[gameid]/[turns]/+server.ts | 59 ++++++++++++ src/routes/api/me/+server.ts | 16 ++++ src/routes/api/token/+server.ts | 11 +-- src/routes/api/users/+server.ts | 4 +- src/routes/games/+page.server.ts | 13 +++ src/routes/games/+page.svelte | 91 ++++++++++--------- .../[gameid]/{+page.ts => +page.server.ts} | 16 +++- src/routes/games/[gameid]/+page.svelte | 46 ++++++++-- src/routes/games/getPageData.ts | 50 ++++++++++ src/routes/login/+page.svelte | 54 +++++++++++ src/routes/register/+page.svelte | 0 src/tests/requests.http | 8 +- vite.config.ts | 3 +- 34 files changed, 698 insertions(+), 151 deletions(-) create mode 100644 src/lib/components/PlayerList.svelte create mode 100644 src/lib/me.ts create mode 100644 src/lib/meContext.ts create mode 100644 src/lib/server/ServerJwtPayload.ts delete mode 100644 src/lib/server/getRequestBody.ts create mode 100644 src/lib/server/requestTools.ts create mode 100644 src/routes/+layout.server.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/api/games/[gameid]/[turns]/+server.ts create mode 100644 src/routes/api/me/+server.ts create mode 100644 src/routes/games/+page.server.ts rename src/routes/games/[gameid]/{+page.ts => +page.server.ts} (65%) create mode 100644 src/routes/games/getPageData.ts create mode 100644 src/routes/login/+page.svelte create mode 100644 src/routes/register/+page.svelte diff --git a/src/lib/GameData.ts b/src/lib/GameData.ts index 255f84e..10a30f7 100644 --- a/src/lib/GameData.ts +++ b/src/lib/GameData.ts @@ -4,7 +4,7 @@ import type { State } from "./State"; export interface GameData { isStarted: boolean; - players: Id[]; + players: { id: Id; username: string }[]; state: State; } @@ -17,7 +17,11 @@ export function isGameData(target: unknown): target is GameData { const { players } = target as any; for (const player of players) { - if (!isId(player)) { + if (!isId(player.id)) { + return false; + } + + if (!hasProperty(player, "username", "string")) { return false; } } diff --git a/src/lib/GameEvent.ts b/src/lib/GameEvent.ts index 26e7c55..2e9d91c 100644 --- a/src/lib/GameEvent.ts +++ b/src/lib/GameEvent.ts @@ -98,10 +98,8 @@ export class SeatPlayers implements GameEvent { /** * RollForFirst takes a player index and a value which represents the pips on the die - * that the player rolled. It represents and attempt to roll the highest die and go + * that the player rolled. It represents an attempt to roll the highest die and go * first. - *axpected to re-roll as a tie breaker. Re-rolling continues until someone - * wins. */ export class RollForFirst implements GameEvent { kind: GameEventKind.RollForFirst; @@ -279,7 +277,7 @@ export class Hold implements GameEvent { // Detect two threes of a kind: if the number of held values was six, and the // set of unique values is two, then there MUST have been two threes of a // kind. - total = 1_500; + total = 1_600; } else { // A player can use a "push" if they are using every one of their rolled // dice. diff --git a/src/lib/Id.ts b/src/lib/Id.ts index b70ae66..6ac3426 100644 --- a/src/lib/Id.ts +++ b/src/lib/Id.ts @@ -1,19 +1,11 @@ import { ObjectId } from "mongodb"; -export type Id = ObjectId; +export type Id = string; export function createId(): Id { - return new ObjectId(); -} - -export function idFromString(str: string) { - return new ObjectId(str); -} - -export function stringFromId(id: Id) { - return id.toString(); + return new ObjectId().toString(); } export function isId(target: unknown): target is Id { - return target instanceof ObjectId; + return typeof target === "string"; } diff --git a/src/lib/ServerResponse.ts b/src/lib/ServerResponse.ts index 89dfb2e..648fa06 100644 --- a/src/lib/ServerResponse.ts +++ b/src/lib/ServerResponse.ts @@ -4,6 +4,7 @@ import { hasProperty } from "$lib/validation"; export type ServerResponse = | { item: Listing } | { items: Listing[] } + | { access_token: string } | { error: string }; export function isServerResponse(target: unknown): target is ServerResponse { @@ -14,5 +15,6 @@ export function isServerResponse(target: unknown): target is ServerResponse { if (hasProperty(target, "item", "object")) return true; if (hasProperty(target, "items", "object[]")) return true; if (hasProperty(target, "error", "string")) return true; + if (hasProperty(target, "access_token", "string")) return true; return false; } diff --git a/src/lib/components/PlayerList.svelte b/src/lib/components/PlayerList.svelte new file mode 100644 index 0000000..e10fbdd --- /dev/null +++ b/src/lib/components/PlayerList.svelte @@ -0,0 +1,32 @@ + + +
+
    + {#each players as player} + {#if me !== null && player.id === me.id} +
  1. you
  2. + {:else} +
  3. {player.username}
  4. + {/if} + {/each} +
+
+ + diff --git a/src/lib/me.ts b/src/lib/me.ts new file mode 100644 index 0000000..347c831 --- /dev/null +++ b/src/lib/me.ts @@ -0,0 +1,14 @@ +import { hasProperty } from "./validation"; + +export interface Me { + id: string; + role: string; + username: string; +} + +export function isMe(target: unknown): target is Me { + if (!hasProperty(target, "id", "string")) return false; + if (!hasProperty(target, "role", "string")) return false; + if (!hasProperty(target, "username", "string")) return false; + return true; +} diff --git a/src/lib/meContext.ts b/src/lib/meContext.ts new file mode 100644 index 0000000..eaa4240 --- /dev/null +++ b/src/lib/meContext.ts @@ -0,0 +1,14 @@ +import { getContext, hasContext, setContext } from "svelte"; +import type { Me } from "./me"; + +export function setMeContext(me: Me) { + setContext("me", me); +} + +export function getMeContext(): Me | null { + if (hasContext("me")) { + return getContext("me"); + } + + return null; +} diff --git a/src/lib/server/Game.ts b/src/lib/server/Game.ts index 2a993bb..6dac908 100644 --- a/src/lib/server/Game.ts +++ b/src/lib/server/Game.ts @@ -3,7 +3,7 @@ import type { GameData } from "../GameData"; import type { State } from "../State"; export class Game implements GameData { - players: Id[]; + players: { id: Id; username: string }[]; isStarted: boolean; state: State; @@ -13,8 +13,17 @@ export class Game implements GameData { this.state = {}; } - addPlayer(id: Id) { - this.players.push(id); + static from(data: GameData) { + const game = new Game(); + game.players = data.players; + game.isStarted = data.isStarted; + game.state = data.state; + + return game; + } + + addPlayer(id: Id, username: string) { + this.players.push({ id, username }); } start() { diff --git a/src/lib/server/ServerJwtPayload.ts b/src/lib/server/ServerJwtPayload.ts new file mode 100644 index 0000000..8212e1d --- /dev/null +++ b/src/lib/server/ServerJwtPayload.ts @@ -0,0 +1,15 @@ +import * as jwt from "jsonwebtoken"; +import { hasProperty } from "../validation"; + +export type ServerJwtPayload = jwt.JwtPayload & { + username: string; + role: string; + sub: string; +}; + +export function isServerJwtPayload(target: unknown): target is ServerJwtPayload { + if (!hasProperty(target, "username", "string")) return false; + if (!hasProperty(target, "role", "string")) return false; + if (!hasProperty(target, "sub", "string")) return false; + return true; +} diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index cfe6bb4..4f29d45 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -3,10 +3,15 @@ import jwt from "jsonwebtoken"; import { type Method, type RouteAuthRule } from "./routeAuth"; import type { Listing } from "$lib/Listing"; import type { LoginData } from "$lib/Login"; +import { isServerJwtPayload, type ServerJwtPayload } from "./ServerJwtPayload"; export type LocalCredentials = ( | { kind: "Basic"; payload: { username: string; password: string } } - | { kind: "Bearer"; payload: jwt.JwtPayload | string } + | { + kind: "Bearer"; + payload: ServerJwtPayload; + role: string; + } | { kind: "None" } ) & { role: string }; @@ -17,13 +22,15 @@ export enum AuthorizationResult { } export async function createToken(listing: Listing, secret: string) { - return await jwt.sign( - { sub: listing.id, username: listing.data.username, role: listing.data.role }, - secret, - { - expiresIn: "1d", - }, - ); + const serverPayload: ServerJwtPayload = { + sub: listing.id, + username: listing.data.username, + role: listing.data.role, + }; + + return await jwt.sign(serverPayload, secret, { + expiresIn: "1d", + }); } export async function authenticate( @@ -31,7 +38,6 @@ export async function authenticate( jwtSecret: string, ): Promise { const authHeader = event.request.headers.get("authorization"); - let tokenKind: "Basic" | "Bearer" | "None"; let tokenRole: string; let tokenDesc: LocalCredentials; @@ -39,27 +45,24 @@ export async function authenticate( if (!authHeader) { // This is a stranger: they have no token and they will be assigned the default // role. - tokenKind = "None"; tokenRole = "default"; tokenDesc = { kind: "None", role: tokenRole }; } else { const [kind, token] = authHeader.split(" "); if (kind === "Bearer") { - tokenKind = "Bearer"; - // The role can be derived from the JWT token. const payload = await jwt.verify(token, jwtSecret); - if (typeof payload === "string") { - // I do not assign, and don't know what to do with, these kinds of - // tokens. Perhaps an error should be logged here, since this is a - // weird thing to have stumbled on. + if (!isServerJwtPayload(payload)) { return null; - // user should have a bearer token } tokenRole = payload.role; - tokenDesc = { kind: "Bearer", payload, role: tokenRole }; + tokenDesc = { + kind: "Bearer", + payload, + role: tokenRole, + }; if (!tokenRole) { // Something has gone wrong: I should not have issued a token without a @@ -70,7 +73,6 @@ export async function authenticate( const decoded = Buffer.from(token, "base64").toString("ascii"); const [username, password] = decoded.split(":"); - tokenKind = "Basic"; tokenRole = "default"; tokenDesc = { kind: "Basic", payload: { username, password }, role: tokenRole }; @@ -95,6 +97,13 @@ export function isAuthorized( const { role: tokenRole, kind: tokenKind } = creds; const rules = roleRules[tokenRole]; + if (parts[0] !== "api") { + // All the routes for the backed server are prefixed with api, and the backed + // server is where all the authorization is going to happen so we will ignore + // calls to the frontend. + return AuthorizationResult.Allowed; + } + let hasMatchingAllow = false; for (const rule of rules) { if (matchesRequest(parts, method, rule)) { diff --git a/src/lib/server/getRequestBody.ts b/src/lib/server/getRequestBody.ts deleted file mode 100644 index 7a2983f..0000000 --- a/src/lib/server/getRequestBody.ts +++ /dev/null @@ -1,16 +0,0 @@ -export async function getRequestBody( - req: Request, - validation?: (target: unknown) => target is T, -): Promise { - if (req.body === null) { - throw new Error("no body is present on the request"); - } - - const body = await req.json(); - - if (validation && !validation(body)) { - throw new Error("body validation failed"); - } - - return body; -} diff --git a/src/lib/server/mongo.ts b/src/lib/server/mongo.ts index 7a89f8a..460c5d9 100644 --- a/src/lib/server/mongo.ts +++ b/src/lib/server/mongo.ts @@ -1,3 +1,4 @@ +import type { Id } from "$lib/Id"; import type { Listing } from "$lib/Listing"; import { MongoClient, @@ -6,10 +7,15 @@ import { type Document, type WithId, } from "mongodb"; -import type { Id } from "../Id"; type ListingFromMongo = Omit & { _id: ObjectId }; +export enum ServerCollections { + Games = "games", + Logins = "logins", + Turns = "turns", +} + const uri = `mongodb://127.0.0.1:27017`; const DATABASE = "ten-thousand"; let cachedClient: MongoClient | null = null; @@ -29,14 +35,23 @@ async function getClient() { return c; } -export async function writeListing(col: string, listing: Listing) { +export async function writeNewListing(col: ServerCollections, listing: Listing) { const client = await getClient(); await client.db(DATABASE).collection(col).insertOne(fixListingForMongo(listing)); } +export async function writeUpdatedListing(col: ServerCollections, listing: Listing) { + const client = await getClient(); + + await client + .db(DATABASE) + .collection(col) + .replaceOne({ _id: new ObjectId(listing.id) }, fixListingForMongo(listing)); +} + export async function listCollection( - col: string, + col: ServerCollections, dataGuard: (target: unknown) => target is Listing, ) { const client = await getClient(); @@ -46,19 +61,30 @@ export async function listCollection( } export async function readListingById( - col: string, + col: ServerCollections, id: Id, dataGuard: (target: unknown) => target is Listing, ) { const client = await getClient(); - const res = await client.db(DATABASE).collection(col).findOne({ _id: id }); + const res = await client + .db(DATABASE) + .collection(col) + .findOne({ _id: idFromString(id) }); if (res === null) return null; return fixListingFromMongo(res, dataGuard); } +export function idFromString(str: string) { + return new ObjectId(str); +} + +export function stringFromId(id: ObjectId) { + return id.toString(); +} + export async function readListingByQuery( - col: string, + col: ServerCollections, query: object, dataGuard: (target: unknown) => target is Listing, ): Promise | null> { @@ -73,7 +99,7 @@ export async function readListingByQuery( function fixListingForMongo(listing: Listing): ListingFromMongo { const { id, ...rest } = listing; return { - _id: id, + _id: idFromString(id), ...rest, }; } @@ -83,7 +109,7 @@ function fixListingFromMongo( dataGuard: (target: unknown) => target is Listing, ): Listing { const { _id, ...rest } = target; - const adjusted = { id: _id, ...rest }; + const adjusted = { id: stringFromId(_id), ...rest }; if (!dataGuard(adjusted)) { throw new Error("the returned document does not conform to the provided type"); diff --git a/src/lib/server/requestTools.ts b/src/lib/server/requestTools.ts new file mode 100644 index 0000000..7780cf7 --- /dev/null +++ b/src/lib/server/requestTools.ts @@ -0,0 +1,46 @@ +import type { LocalCredentials } from "./auth"; + +export enum ResourceId { + Game = "gameid", +} + +export function getUser(locals: { user: LocalCredentials }) { + // SvelteKit screws up this type somehow, and it's important to explicitely set it + // here. + const user: LocalCredentials = locals.user; + + if (user.kind !== "Bearer") { + throw new Error("bad user information"); + } + + return user; +} + +export function getParam(params: Partial>, resource: ResourceId) { + const id = params[resource]; + + if (!id) { + return null; + } + + return id; +} + +export async function getBody( + request: Request, + dataGuard: (body: unknown) => body is T, +) { + let body: unknown; + + try { + body = await request.json(); + } catch (err) { + return null; + } + + if (!dataGuard(body)) { + throw new Error("data guard failed"); + } + + return body; +} diff --git a/src/lib/server/responseBodies.ts b/src/lib/server/responseBodies.ts index 3a038a5..5378f8b 100644 --- a/src/lib/server/responseBodies.ts +++ b/src/lib/server/responseBodies.ts @@ -6,6 +6,14 @@ export function singleResponse(item: unknown) { return Response.json({ item }); } +export function createdResponse(id: string) { + return Response.json({ item: id }, { status: 201 }); +} + +export function tokenResponse(token: string) { + return Response.json({ access_token: token }); +} + export function badRequestResponse(error: string = "Bad Request") { return Response.json({ error }, { status: 400 }); } diff --git a/src/lib/server/test/auth.spec.ts b/src/lib/server/test/auth.spec.ts index 806fd45..25cc346 100644 --- a/src/lib/server/test/auth.spec.ts +++ b/src/lib/server/test/auth.spec.ts @@ -420,6 +420,20 @@ describe("auth", () => { result: AuthorizationResult.Unauthenticated, }, }, + { + heading: "allows a user to hit any endpoint that doesn't start with api", + conditions: { + token: { + kind: "None", + role: "default", + }, + method: "GET", + path: "/resource", + }, + expectations: { + result: AuthorizationResult.Allowed, + }, + }, { heading: "correctly matches a route with a trailing /", conditions: { diff --git a/src/lib/test/ServerResponse.spec.ts b/src/lib/test/ServerResponse.spec.ts index 8b861a3..b5615d2 100644 --- a/src/lib/test/ServerResponse.spec.ts +++ b/src/lib/test/ServerResponse.spec.ts @@ -39,9 +39,14 @@ describe("ServerResponse", () => { error: "something is wrong", }; + const accessTokenResponse = { + access_token: "Bearer somekindoftokenfromtheserver", + }; + expect(isServerResponse(singleServerResponse)).to.be.true; expect(isServerResponse(listServerResponse)).to.be.true; expect(isServerResponse(errorServerResponse)).to.be.true; + expect(isServerResponse(accessTokenResponse)).to.be.true; }); }); }); diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..3acbecf --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,49 @@ +import { isServerResponse } from "$lib/ServerResponse"; +import { redirect, type ServerLoad } from "@sveltejs/kit"; +import { isMe, type Me } from "$lib/me"; +import { setMeContext } from "$lib/meContext"; + +export const load: ServerLoad = async ({ request, cookies, fetch }) => { + const url = new URL(request.url); + const token = cookies.get("access_token"); + let me: Me | null = null; + + if (token) { + const res = await fetch("/api/me", { + headers: [["authorization", token]], + }); + + const body = await res.json(); + + if (!isServerResponse(body)) { + throw new Error("missing or malformed body"); + } + + if (!("item" in body)) { + throw new Error("expected to receive an item"); + } + + const item = body.item; + + if (!isMe(item)) { + throw new Error("expected to receive users 'me' object"); + } + + me = item; + } + + if (!token && url.pathname !== "/login" && url.pathname !== "/login/__data.json") { + console.log("REDIRECTING", url.pathname); + const baseUrl = `${url.protocol}${url.host}`; + const loginUrl = new URL("login", baseUrl); + loginUrl.searchParams.set("to", url.pathname); + redirect(302, loginUrl); + } else { + console.log("no need to redirect"); + } + + return { + token, + me, + }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..8934f6b --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,22 @@ + + +
+ {@render children()} +
+ + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cc88df0..a31ce05 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,2 +1,73 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+

Play Ten Thousand!

+Find a game +

What is Ten Thousand?

+

+ Ten Thousand is a dice game where players take turns rolling dice and scoring points to + try to reach 10,000 points. +

+

How do I play?

+

+ The game starts with each player rolling to see who goes first. Each player then takes + turns rolling up to six dice, putting aside any scoring dice. Each time a player scores, + they can roll again to try to score more. Be careful, however, because if a player rolls + and doesn't score any points, they lose all the points they have scored during their + turn and their turn ends. +

+

+ There are many variations to this game, this server uses the variation that I was taught + and have played many times, often obnoxiously loudly, at bars. +

+

Basic Rules

+

+ Players start with six dice. Each die or dice they chose to score with, they put aside + and then roll with all the remaining dice. If all the dice have been rolled and set + aside for scoring, the player gets to re-roll all the dice, keeping whatever score they + already rolled up to that point. The player does not have to set aside scoring dice, and + can opt instead to re-roll them, hoping for a better score. +

+

+ Once a player decides to hold, all the points they scored during their turn officially + get added to their score. A player can hold with as many or as few points as they like + with two exceptions: if a player can roll all six dice, they must roll + them; and the first time a player holds for points, their score must be worth at + least 1,000 points. +

+

+ The final special scenario is called a "push." If a player has exactly two non-scoring + dice remaining, and those dice make a two of a kind (⚃ ⚃) then the player + takes those as if they were scoring dice worth zero points. Pushes can occur if a player + has decided to score all other dice which they have just rolled, and the dice they have + remaining are a push, or if the player has rolled two dice, and that roll has resulted + in a two of a kind. If a player has a push, they must take it, and since a push + can only result in six dice being available to roll again, a player who has rolled a push + must also roll again. +

+

+

+ Once a player has hit 10,000, each other player gets one more turn. At the end, the + player with the most points (not necessarily the player who hit 10,000 first) wins. +

+

Scoring

+
    +
  1. Ones (⚀) can be scored on their own for 100 points
  2. +
  3. Fives (⚄) can be scored on their own for 50 points
  4. +
  5. + Three of a kind (⚁ ⚁ ⚁) can be held for their face value, x100, + e.g., three twos are worth 200 points. The only exception is ones, three ones are + worth 1,000 points. +
  6. +
  7. + Four of a kind (⚂ ⚂ ⚂ ⚂) and five of a kind (⚂ ⚂ + ⚂ ⚂ ⚂) and six of a kind (⚂ ⚂ ⚂ ⚂ ⚂ + ⚂) are treated like a three of a kind where each additional matching die is + worth x2. So four threes is 600 (300 x 2), five threes is 1,200 (300 x 2 x 2), and six + threes are worth 2,400 (300 x 2 x 2 x 2). +
  8. +
  9. + Two threes of a kind (⚅ ⚅ ⚅ ⚃ ⚃ ⚃) is worth 1,600 + points. +
  10. +
  11. + A run of 6 (⚀ ⚁ ⚂ ⚃ ⚄ ⚅) is worth 2,000 points. +
  12. +
diff --git a/src/routes/api/games/+server.ts b/src/routes/api/games/+server.ts index 9c4e9c2..d8a1edf 100644 --- a/src/routes/api/games/+server.ts +++ b/src/routes/api/games/+server.ts @@ -2,19 +2,26 @@ import type { RequestHandler } from "@sveltejs/kit"; import { listResponse, singleResponse } from "$lib/server/responseBodies"; import { createNewListing } from "$lib/server/modifyListing"; import { Game } from "$lib/server/Game"; -import { listCollection, writeListing } from "$lib/server/mongo"; +import { listCollection, ServerCollections, writeNewListing } from "$lib/server/mongo"; import { isListing } from "$lib/Listing"; import type { GameData } from "$lib/GameData"; +import { getUser } from "$lib/server/requestTools"; export const GET: RequestHandler = async (): Promise => { - const games = await listCollection("games", isListing); + const games = await listCollection(ServerCollections.Games, isListing); return listResponse(games); }; -export const POST: RequestHandler = async (): Promise => { - const newListing = createNewListing(new Game()); +export const POST: RequestHandler = async ({ locals }): Promise => { + const user = getUser(locals); + const { username, sub } = user.payload; + const game = new Game(); - await writeListing("games", newListing); + game.addPlayer(sub, username); + + const newListing = createNewListing(game); + + await writeNewListing(ServerCollections.Games, newListing); return singleResponse(newListing.id); }; diff --git a/src/routes/api/games/[gameid]/+server.ts b/src/routes/api/games/[gameid]/+server.ts index d7f99f5..d7f2611 100644 --- a/src/routes/api/games/[gameid]/+server.ts +++ b/src/routes/api/games/[gameid]/+server.ts @@ -1,7 +1,7 @@ import type { GameData } from "$lib/GameData"; -import { idFromString, type Id } from "$lib/Id"; import { isListing } from "$lib/Listing"; -import { readListingById } from "$lib/server/mongo"; +import { readListingById, ServerCollections } from "$lib/server/mongo"; +import { getParam, ResourceId } from "$lib/server/requestTools"; import { badRequestResponse, notFoundResponse, @@ -10,20 +10,13 @@ import { import type { RequestHandler } from "@sveltejs/kit"; export const GET: RequestHandler = async ({ params }): Promise => { - const idStr = params["gameid"]; + const id = getParam(params, ResourceId.Game); - if (!idStr) { + if (!id) { return badRequestResponse("missing gameid parameter"); } - let id: Id; - try { - id = idFromString(idStr); - } catch (err) { - return notFoundResponse(); - } - - const game = await readListingById("games", id, isListing); + const game = await readListingById(ServerCollections.Games, id, isListing); if (!game) { return notFoundResponse(); diff --git a/src/routes/api/games/[gameid]/[turns]/+server.ts b/src/routes/api/games/[gameid]/[turns]/+server.ts new file mode 100644 index 0000000..a51ba2c --- /dev/null +++ b/src/routes/api/games/[gameid]/[turns]/+server.ts @@ -0,0 +1,59 @@ +import type { GameData } from "$lib/GameData"; +import { getGameEvent, isGameEventData } from "$lib/GameEvent"; +import { isListing } from "$lib/Listing"; +import { updateListing } from "$lib/server/modifyListing"; +import { createNewListing } from "$lib/server/modifyListing"; +import { writeNewListing } from "$lib/server/mongo"; +import { + readListingById, + ServerCollections, + writeUpdatedListing, +} from "$lib/server/mongo"; +import { getBody, getParam, getUser, ResourceId } from "$lib/server/requestTools"; +import { + badRequestResponse, + createdResponse, + notFoundResponse, + singleResponse, +} from "$lib/server/responseBodies"; +import type { RequestHandler } from "@sveltejs/kit"; + +export const POST: RequestHandler = async ({ + locals, + params, + request, +}): Promise => { + const id = getParam(params, ResourceId.Game); + + if (id === null) { + return badRequestResponse("missing gameid parameter"); + } + + const body = await getBody(request, isGameEventData); + + if (!body) { + return badRequestResponse("missing game event in body"); + } + + const user = getUser(locals); + const game = await readListingById(ServerCollections.Games, id, isListing); + + if (!game) { + return notFoundResponse(); + } + + const { state } = game.data; + const event = getGameEvent(game.data, body); + + try { + event.run(state); + game.data.isStarted = true; + } catch (err) { + return badRequestResponse(`illegal turn: ${err}`); + } + + await writeNewListing(ServerCollections.Turns, createNewListing(body)); + await writeUpdatedListing(ServerCollections.Games, updateListing(game, game.data)); + + return singleResponse(game); +}; diff --git a/src/routes/api/me/+server.ts b/src/routes/api/me/+server.ts new file mode 100644 index 0000000..735d741 --- /dev/null +++ b/src/routes/api/me/+server.ts @@ -0,0 +1,16 @@ +import type { LocalCredentials } from "$lib/server/auth"; +import { singleResponse } from "$lib/server/responseBodies"; +import { error, type RequestHandler } from "@sveltejs/kit"; + +export const GET: RequestHandler = async ({ locals }): Promise => { + const user: LocalCredentials = locals.user; + if (user.kind !== "Bearer") { + error(401, "user not logged in"); + } + + return singleResponse({ + id: user.payload.sub, + username: user.payload.username, + role: user.role, + }); +}; diff --git a/src/routes/api/token/+server.ts b/src/routes/api/token/+server.ts index 9994e7f..f4df838 100644 --- a/src/routes/api/token/+server.ts +++ b/src/routes/api/token/+server.ts @@ -1,10 +1,9 @@ import { isListing } from "$lib/Listing"; import { isLoginData } from "$lib/Login"; -import { readListingByQuery } from "$lib/server/mongo"; +import { readListingByQuery, ServerCollections } from "$lib/server/mongo"; import { badRequestResponse, - notFoundResponse, - singleResponse, + tokenResponse, unauthorizedResponse, } from "$lib/server/responseBodies"; import type { RequestHandler } from "@sveltejs/kit"; @@ -22,7 +21,7 @@ export const POST: RequestHandler = async ({ locals }): Promise => { const { username, password } = user.payload; const listing = await readListingByQuery( - "logins", + ServerCollections.Logins, { "data.username": username, }, @@ -30,12 +29,12 @@ export const POST: RequestHandler = async ({ locals }): Promise => { ); if (!listing) { - return notFoundResponse(); + return unauthorizedResponse(); } if (await compare(password, listing.data.password)) { const token = await createToken(listing, JWT_SECRET); - return singleResponse(token); + return tokenResponse(token); } return badRequestResponse("wrong password"); diff --git a/src/routes/api/users/+server.ts b/src/routes/api/users/+server.ts index f5a9ef2..a61080e 100644 --- a/src/routes/api/users/+server.ts +++ b/src/routes/api/users/+server.ts @@ -1,6 +1,6 @@ import { hashPassword, isLoginData } from "$lib/Login"; import { createNewListing } from "$lib/server/modifyListing"; -import { writeListing } from "$lib/server/mongo"; +import { ServerCollections, writeNewListing } from "$lib/server/mongo"; import { badRequestResponse, forbiddenResponse, @@ -30,7 +30,7 @@ export const POST: RequestHandler = async ({ request }): Promise => { const listing = createNewListing(body); try { - await writeListing("logins", listing); + await writeNewListing(ServerCollections.Logins, listing); return singleResponse(listing.id); } catch (err) { return serverErrorResponse(); diff --git a/src/routes/games/+page.server.ts b/src/routes/games/+page.server.ts new file mode 100644 index 0000000..ad0c583 --- /dev/null +++ b/src/routes/games/+page.server.ts @@ -0,0 +1,13 @@ +import { error } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; +import { getPageData } from "./getPageData"; + +export const load: PageServerLoad = async ({ fetch, cookies }) => { + const token = cookies.get("access_token"); + + if (!token) { + error(401); + } + + return getPageData(fetch, token); +}; diff --git a/src/routes/games/+page.svelte b/src/routes/games/+page.svelte index 769533c..56fea7d 100644 --- a/src/routes/games/+page.svelte +++ b/src/routes/games/+page.svelte @@ -1,52 +1,59 @@ -
-

Let’s Play Ten Thousand

- - - - - - - - - - - {#each games as game} - {@render GameRow(game)} - {/each} - -
No.NamePlayers
-
+

Let’s Play Ten Thousand

-{#snippet GameRow (game: GameData)} - - {game.id} - {game.name} - {game.players.length} - +

Games

+
+ {#each games as game} + {@render GameRow(game)} + {/each} +
+ +{#snippet GameRow(game: Listing)} +
+
{prettyDate(new Date(game.createdAt))}
+
+ {#each games as game} + + {/each} +
+
+ +
+
{/snippet} diff --git a/src/routes/games/[gameid]/+page.ts b/src/routes/games/[gameid]/+page.server.ts similarity index 65% rename from src/routes/games/[gameid]/+page.ts rename to src/routes/games/[gameid]/+page.server.ts index 4a82b58..80b0188 100644 --- a/src/routes/games/[gameid]/+page.ts +++ b/src/routes/games/[gameid]/+page.server.ts @@ -2,15 +2,21 @@ import { isGameData, type GameData } from "$lib/GameData"; import { isListing } from "$lib/Listing"; import { isServerResponse } from "$lib/ServerResponse"; import { error } from "@sveltejs/kit"; -import type { PageLoad } from "./$types"; +import type { PageServerLoad } from "./$types"; -export const load: PageLoad = async ({ fetch, params }) => { +export const load: PageServerLoad = async ({ fetch, params, cookies }) => { const url = `/api/games/${params.gameid}`; let res: Response; let body: unknown; + const token = cookies.get("access_token"); + + if (!token) { + error(401); + } + try { - res = await fetch(url); + res = await fetch(url, { headers: [["Authorization", token]] }); body = await res.json(); } catch (err) { error(500, "unable to call API"); @@ -25,8 +31,8 @@ export const load: PageLoad = async ({ fetch, params }) => { } if ("item" in body && isListing(body.item, isGameData)) { - return body.item; + return { game: body.item }; } else { - error(500, "expected response body to contain game data"); + error(res.status, "unable to fetch game data"); } }; diff --git a/src/routes/games/[gameid]/+page.svelte b/src/routes/games/[gameid]/+page.svelte index 79e642a..b806597 100644 --- a/src/routes/games/[gameid]/+page.svelte +++ b/src/routes/games/[gameid]/+page.svelte @@ -1,15 +1,43 @@ -
-

This is game {data.id}

-
+{#if page.game.data.isStarted} +

This is game {page.game.id}

+{:else} +

This is some lobby

+
+
+ +
+
+
+
+ +
+{/if} diff --git a/src/routes/games/getPageData.ts b/src/routes/games/getPageData.ts new file mode 100644 index 0000000..06e912a --- /dev/null +++ b/src/routes/games/getPageData.ts @@ -0,0 +1,50 @@ +import { isGameData, type GameData } from "$lib/GameData"; +import { isListing, type Listing } from "$lib/Listing"; +import { isServerResponse } from "$lib/ServerResponse"; +import { error } from "@sveltejs/kit"; + +export async function getPageData( + svelteKitFetch: typeof fetch, + token: string, +): Promise<{ games: Listing[] }> { + const url = "/api/games"; + let res: Response; + let body: unknown; + + try { + res = await svelteKitFetch(url, { + headers: [["Authorization", token]], + }); + body = await res.json(); + } catch (err) { + console.log(err); + error(500, "unable to call API"); + } + + if (res.status === 404) { + error(404, `Not Found`); + } + + if (!isServerResponse(body)) { + console.log("wasn't server response"); + error(500, "expected to receive a properly formatted server response body"); + } + + if ("items" in body) { + if ( + body.items.reduce((result, item) => { + if (result) { + return isListing(item, isGameData); + } + + return false; + }, true) + ) { + return { games: body.items as Listing[] }; + } else { + error(500, "malformed API response"); + } + } else { + error(res.status, "unable to fetch game data"); + } +} diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..5df4660 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,54 @@ + + +
+

Login

+
+
+ + +
+
+ + +
+
+ +
+
+
diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/requests.http b/src/tests/requests.http index 14e0fdc..0c27ebb 100644 --- a/src/tests/requests.http +++ b/src/tests/requests.http @@ -35,15 +35,15 @@ Content-Type: application/json ### -POST https://localhost:5173/api/games/de4cdb8c-0346-4ac6-a7a8-b4135b2d79e3/turns +POST https://localhost:5173/api/games/67b39573a0fcb80dd13f2c8b/turns Accept: application/json Content-Type: application/json Authorization: Bearer {{token}} { - "kind": "Roll", - "player": 2, - "value": 4 + "kind": "SeatPlayers", + "player": 1, + "value": 2 } ### diff --git a/vite.config.ts b/vite.config.ts index 92570e2..02f6dfe 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { configDefaults, coverageConfigDefaults, defineConfig } from "vitest/config"; +import { coverageConfigDefaults, defineConfig } from "vitest/config"; import { sveltekit } from "@sveltejs/kit/vite"; import { readFileSync } from "fs"; @@ -18,5 +18,6 @@ export default defineConfig({ coverage: { exclude: [...coverageConfigDefaults.exclude, "svelte.config.js"], }, + environment: "jsdom", }, });