diff --git a/deno.json b/deno.json index 53af4e4..7199e91 100644 --- a/deno.json +++ b/deno.json @@ -54,6 +54,7 @@ }, "fmt": { - "lineWidth": 110 + "lineWidth": 110, + "exclude": ["tmp"] } } diff --git a/src/server/app.ts b/src/server/app.ts deleted file mode 100644 index 2cb894a..0000000 --- a/src/server/app.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Application } from "@oak/oak/application"; -import { apiMiddleware } from "./routes-api/middleware.ts"; -import { socketMiddleware } from "./routes-socket/middleware.ts"; -import { makeSpaRoute, makeStaticRoute } from "./routes.static.ts"; -import { notFound } from "./routes.error.ts"; - -export const app = new Application(); -app.use(apiMiddleware("/api")); -app.use(socketMiddleware("/stream")); - -const root = `${Deno.cwd()}/dist`; -app.use(makeStaticRoute({ root, index: "index.html" })); -app.use(makeSpaRoute({ root, path: "/index.html" })); -app.use(notFound); diff --git a/src/server/main.ts b/src/server/main.ts index 15069cf..cf26658 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -1,5 +1,10 @@ import { parseArgs } from "jsr:@std/cli/parse-args"; -import { app } from "./app.ts"; +import { Application } from "@oak/oak/application"; + +import { apiMiddleware } from "./routes-api/middleware.ts"; +import { notFound } from "./routes-error.ts"; +import { socketMiddleware } from "./routes-socket/middleware.ts"; +import { makeSpaRoute, makeStaticRoute } from "./routes-static.ts"; const flags = parseArgs(Deno.args, { string: ["port", "host"], @@ -9,6 +14,15 @@ const flags = parseArgs(Deno.args, { }, }); +const app = new Application(); +app.use(apiMiddleware("/api")); +app.use(socketMiddleware("/stream")); + +const root = `${Deno.cwd()}/dist`; +app.use(makeStaticRoute({ root, index: "index.html" })); +app.use(makeSpaRoute({ root, path: "/index.html" })); +app.use(notFound); + console.log(` skypod ⚡️ http://${flags.host}:${flags.port} `); diff --git a/src/server/routes.error.ts b/src/server/routes-error.ts similarity index 100% rename from src/server/routes.error.ts rename to src/server/routes-error.ts diff --git a/src/server/routes-socket/handler-error.ts b/src/server/routes-socket/handler-error.ts new file mode 100644 index 0000000..72dc935 --- /dev/null +++ b/src/server/routes-socket/handler-error.ts @@ -0,0 +1,22 @@ +import { z } from "zod/v4"; +import { BaseError, ValidationError } from "@repo/common/errors.ts"; + +export function errorHandler(ws: WebSocket, e: unknown) { + if (e instanceof BaseError) { + ws.send(`${e.message}\n`); + console.warn(`${e.status} ${e.constructor.name}`); + } else if (e instanceof z.ZodError) { + const validation = new ValidationError(e); + ws.send(`${validation.message}\n`); + console.warn(`${validation.status} ValidationError`); + } else if (e instanceof DOMException && e.name === "TimeoutError") { + ws.send(`408 Request Timeout: operation timed out\n`); + console.warn(`408 TimeoutError`); + } else if (e instanceof DOMException && e.name === "AbortError") { + ws.send(`499 Client Closed Request: operation was aborted\n`); + console.warn(`499 AbortError`); + } else { + ws.send(`500 Internal Server Error: ${e}\n`); + console.error(e); + } +} diff --git a/src/server/routes-socket/handler-preauth.ts b/src/server/routes-socket/handler-preauth.ts new file mode 100644 index 0000000..ccfafdf --- /dev/null +++ b/src/server/routes-socket/handler-preauth.ts @@ -0,0 +1,52 @@ +import { jwtVerify } from "@panva/jose"; + +import { combineSignals, timeoutSignal } from "@repo/common/aborts.ts"; +import { AuthError, normalizeError } from "@repo/common/errors.ts"; +import { takeSocket } from "@repo/common/socket.ts"; +import { StrictMap } from "@repo/common/strict-map.ts"; +import { jwtDecodedSchema } from "@repo/schema/crypto.ts"; +import { preauthAuthnMessageSchema } from "@repo/schema/proto.ts"; +import { parseIdentId, parseRealmId } from "@repo/schema/state.ts"; + +import { Realm, realmMap, Session } from "./state.ts"; + +export async function preauthHandler( + ws: WebSocket, + signal: AbortSignal, +): Promise { + const timeout = timeoutSignal(3000); + const combinedSignal = combineSignals(signal, timeout.signal); + + // wait for preauth.authn message;it will be signed by the identity, + // which we already have if the realm exists, and we add to a new realm if being created + try { + const data = await takeSocket(ws, combinedSignal); + const jwt = jwtDecodedSchema.parse(data); + + const msg = await preauthAuthnMessageSchema.parseAsync(jwt); + const realmid = parseRealmId(jwt.aud); + const identid = parseIdentId(jwt.iss); + + // make sure we have a realm + // if a new one is being created, the current identity to it + const realm = realmMap.ensure(realmid, () => ({ + id: realmid, + sockets: new StrictMap(), + identities: new StrictMap([[identid, msg.pubkey]]), + })); + + try { + // we've looked up the realm + // pubkey should match what we've got stored, and the signature should be valid + const pubkey = realm.identities.require(identid); + await jwtVerify(data, pubkey); + + // the signature is good + return { realm, realmid, identid, pubkey }; + } catch (e) { + throw new AuthError("jwt verification failed", normalizeError(e)); + } + } finally { + timeout.cleanup(); + } +} diff --git a/src/server/routes-socket/handler-realm.ts b/src/server/routes-socket/handler-realm.ts new file mode 100644 index 0000000..8221519 --- /dev/null +++ b/src/server/routes-socket/handler-realm.ts @@ -0,0 +1,10 @@ +import { Realm, Session } from "./state.ts"; +import { streamSocket } from "../../common/socket.ts"; + +export async function realmHandler(ws: WebSocket, realm: Realm, auth: Session, signal: AbortSignal) { + ws.send(`welcome ${auth.identid} to ${auth.realmid} - ${realm.identities.keys()} `); + + for await (const message of streamSocket(ws, { signal })) { + ws.send(`you said: ${message}`); + } +} diff --git a/src/server/routes-socket/handler.ts b/src/server/routes-socket/handler.ts index 2aba0fb..a698a1c 100644 --- a/src/server/routes-socket/handler.ts +++ b/src/server/routes-socket/handler.ts @@ -1,35 +1,9 @@ -import { takeSocket } from "@repo/common/socket.ts"; -import { combineSignals, timeoutSignal } from "@repo/common/aborts.ts"; -import { - AuthError, - BaseError, - CancellationError, - normalizeError, - ValidationError, -} from "@repo/common/errors.ts"; -import { z } from "zod/v4"; +import { CancellationError } from "@repo/common/errors.ts"; -import { StrictMap } from "@repo/common/strict-map.ts"; -import { IdentId, parseIdentId, parseRealmId, RealmId } from "@repo/schema/state.ts"; -import { jwtDecodedSchema } from "@repo/schema/crypto.ts"; -import { preauthAuthnMessageSchema } from "@repo/schema/proto.ts"; -import { jwtVerify } from "@panva/jose/jwt/verify"; - -type Realm = { - id: RealmId; - sockets: StrictMap; - identities: StrictMap; -}; - -const realmMap = new StrictMap(); - -function attachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { - realm.sockets.update(ident, (ss) => ss ? [...ss, socket] : [socket]); -} - -function detachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { - realm.sockets.update(ident, (ss) => ss ? ss.filter((s) => s !== socket) : []); -} +import { errorHandler } from "./handler-error.ts"; +import { preauthHandler } from "./handler-preauth.ts"; +import { attachSocket, detachSocket } from "./state.ts"; +import { realmHandler } from "./handler-realm.ts"; export async function socketHandler(this: WebSocket) { const controller = new AbortController(); @@ -40,8 +14,10 @@ export async function socketHandler(this: WebSocket) { try { const { realm, ...auth } = await preauthHandler(this, controller.signal); + try { attachSocket(realm, auth.identid, this); + await realmHandler(this, realm, auth, controller.signal); } finally { detachSocket(realm, auth.identid, this); } @@ -54,61 +30,3 @@ export async function socketHandler(this: WebSocket) { } } } - -async function preauthHandler(ws: WebSocket, signal: AbortSignal) { - const timeout = timeoutSignal(3000); - const combinedSignal = combineSignals(signal, timeout.signal); - - // wait for preauth.authn message;it will be signed by the identity, - // which we already have if the realm exists, and we add to a new realm if being created - try { - const data = await takeSocket(ws, combinedSignal); - const jwt = jwtDecodedSchema.parse(data); - - const msg = await preauthAuthnMessageSchema.parseAsync(jwt); - const realmid = parseRealmId(jwt.aud); - const identid = parseIdentId(jwt.iss); - - // make sure we have a realm - // if a new one is being created, the current identity to it - const realm = realmMap.ensure(realmid, () => ({ - id: realmid, - sockets: new StrictMap(), - identities: new StrictMap([[identid, msg.pubkey]]), - })); - - try { - // we've looked up the realm - // pubkey should match what we've got stored, and the signature should be valid - const pubkey = realm.identities.require(identid); - await jwtVerify(data, pubkey); - - // the signature is good - return { realm, realmid, identid, pubkey }; - } catch (e) { - throw new AuthError("jwt verification failed", normalizeError(e)); - } - } finally { - timeout.cleanup(); - } -} - -function errorHandler(ws: WebSocket, e: unknown) { - if (e instanceof BaseError) { - ws.send(`${e.message}\n`); - console.warn(`${e.status} ${e.constructor.name}`); - } else if (e instanceof z.ZodError) { - const validation = new ValidationError(e); - ws.send(`${validation.message}\n`); - console.warn(`${validation.status} ValidationError`); - } else if (e instanceof DOMException && e.name === "TimeoutError") { - ws.send(`408 Request Timeout: operation timed out\n`); - console.warn(`408 TimeoutError`); - } else if (e instanceof DOMException && e.name === "AbortError") { - ws.send(`499 Client Closed Request: operation was aborted\n`); - console.warn(`499 AbortError`); - } else { - ws.send(`500 Internal Server Error: ${e}\n`); - console.error(e); - } -} diff --git a/src/server/routes-socket/state.ts b/src/server/routes-socket/state.ts new file mode 100644 index 0000000..76373a1 --- /dev/null +++ b/src/server/routes-socket/state.ts @@ -0,0 +1,24 @@ +import { StrictMap } from "@repo/common/strict-map.ts"; +import { IdentId, RealmId } from "@repo/schema/state.ts"; + +export type Realm = { + id: RealmId; + sockets: StrictMap; + identities: StrictMap; +}; + +export const realmMap = new StrictMap(); + +export function attachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { + realm.sockets.update(ident, (ss) => ss ? [...ss, socket] : [socket]); +} + +export function detachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { + realm.sockets.update(ident, (ss) => ss ? ss.filter((s) => s !== socket) : []); +} + +export type Session = { + realmid: RealmId; + identid: IdentId; + pubkey: CryptoKey; +}; diff --git a/src/server/routes.static.ts b/src/server/routes-static.ts similarity index 100% rename from src/server/routes.static.ts rename to src/server/routes-static.ts