From b3855319e52c7776e5624f18edf9987c2cfce049 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 27 Jun 2025 01:52:09 +0000 Subject: [PATCH] chore: update serializable type validation to check for wider range of cbor types instead of json types --- examples/better-auth/src/backend/auth.ts | 24 +++-- examples/better-auth/src/backend/registry.ts | 33 ++++--- examples/better-auth/src/backend/server.ts | 59 ++++-------- .../src/frontend/components/AuthForm.tsx | 16 ++++ .../src/frontend/components/ChatRoom.tsx | 12 ++- packages/core/src/actor/errors.ts | 2 +- packages/core/src/actor/instance.ts | 6 +- packages/core/src/common/utils.ts | 89 +++++++++++++++++-- pnpm-lock.yaml | 14 +++ 9 files changed, 171 insertions(+), 84 deletions(-) diff --git a/examples/better-auth/src/backend/auth.ts b/examples/better-auth/src/backend/auth.ts index c204f1e62..1599dc4cf 100644 --- a/examples/better-auth/src/backend/auth.ts +++ b/examples/better-auth/src/backend/auth.ts @@ -1,21 +1,17 @@ import { betterAuth } from "better-auth"; -import { sqliteAdapter } from "@better-auth/sqlite"; -import Database from "better-sqlite3"; - -const db = new Database("./auth.db"); export const auth = betterAuth({ - // IMPORTANT: Connect your own database here - database: sqliteAdapter(db), + // IMPORTANT: Connect a real database for productoin use cases + // + // https://www.better-auth.com/docs/installation#create-database-tables + // database: memoryAdapter({ + // user: [], + // account: [], + // session: [], + // verifcation: [], + // }), + trustedOrigins: ["http://localhost:5173"], emailAndPassword: { enabled: true, }, - session: { - expiresIn: 60 * 60 * 24 * 7, // 7 days - updateAge: 60 * 60 * 24, // 1 day (every day the session expiry is updated) - }, - plugins: [], }); - -export type Session = typeof auth.$Infer.Session; -export type User = typeof auth.$Infer.User; diff --git a/examples/better-auth/src/backend/registry.ts b/examples/better-auth/src/backend/registry.ts index 8d2a17003..d0b117964 100644 --- a/examples/better-auth/src/backend/registry.ts +++ b/examples/better-auth/src/backend/registry.ts @@ -1,31 +1,43 @@ -import { actor, setup } from "@rivetkit/actor"; -import { auth, type Session, type User } from "./auth"; +import { actor, OnAuthOptions, setup, UserError } from "@rivetkit/actor"; +import { auth } from "./auth"; + +interface State { + messages: Message[]; +} + +interface Message { + id: string; + userId: string; + username: string; + message: string; + timestamp: number; +} export const chatRoom = actor({ - onAuth: async (c) => { + onAuth: async (c: OnAuthOptions) => { const authResult = await auth.api.getSession({ headers: c.req.headers, }); + console.log("auth result", authResult); if (!authResult?.session || !authResult?.user) { - throw new Error("Unauthorized"); + throw new UserError("Unauthorized"); } return { - userId: authResult.user.id, user: authResult.user, session: authResult.session, }; }, - state: { - messages: [] as Array<{ id: string; userId: string; username: string; message: string; timestamp: number }> - }, + state: { + messages: [], + } as State, actions: { sendMessage: (c, message: string) => { const newMessage = { id: crypto.randomUUID(), - userId: c.auth.userId, - username: c.auth.user.email, + userId: "TODO", + username: c.conn.auth.user.email || "Unknown", message, timestamp: Date.now(), }; @@ -44,4 +56,3 @@ export const chatRoom = actor({ export const registry = setup({ use: { chatRoom }, }); - diff --git a/examples/better-auth/src/backend/server.ts b/examples/better-auth/src/backend/server.ts index dc9b9420a..25ee05782 100644 --- a/examples/better-auth/src/backend/server.ts +++ b/examples/better-auth/src/backend/server.ts @@ -1,54 +1,27 @@ import { registry } from "./registry"; import { auth } from "./auth"; import { Hono } from "hono"; -import { serve } from "@hono/node-server"; +import { cors } from "hono/cors"; + +// Start RivetKit +const { client, hono, serve } = registry.createServer(); // Setup router const app = new Hono(); -// Start RivetKit -const { client, hono } = registry.run({ - driver: createMemoryDriver(), - cors: { - // IMPORTANT: Configure origins in production - origin: "*", - }, -}); +app.use( + "*", + cors({ + origin: ["http://localhost:5173"], + allowHeaders: ["Content-Type", "Authorization"], + allowMethods: ["POST", "GET", "OPTIONS"], + exposeHeaders: ["Content-Length"], + maxAge: 600, + credentials: true, + }), +); // Mount Better Auth routes app.on(["GET", "POST"], "/api/auth/**", (c) => auth.handler(c.req.raw)); -// Expose RivetKit to the frontend -app.route("/registry", hono); - -// Example HTTP endpoint to join chat room -app.post("/api/join-room/:roomId", async (c) => { - const roomId = c.req.param("roomId"); - - // Verify authentication - const authResult = await auth.api.getSession({ - headers: c.req.header(), - }); - - if (!authResult?.session || !authResult?.user) { - return c.json({ error: "Unauthorized" }, 401); - } - - try { - const room = client.chatRoom.getOrCreate(roomId); - const messages = await room.getMessages(); - - return c.json({ - success: true, - roomId, - messages, - user: authResult.user - }); - } catch (error) { - return c.json({ error: "Failed to join room" }, 500); - } -}); - -serve({ fetch: app.fetch, port: 8080 }, () => - console.log("Listening at http://localhost:8080"), -); +serve(app); diff --git a/examples/better-auth/src/frontend/components/AuthForm.tsx b/examples/better-auth/src/frontend/components/AuthForm.tsx index 005e89696..352c876a0 100644 --- a/examples/better-auth/src/frontend/components/AuthForm.tsx +++ b/examples/better-auth/src/frontend/components/AuthForm.tsx @@ -8,6 +8,7 @@ interface AuthFormProps { export function AuthForm({ onAuthSuccess }: AuthFormProps) { const [isLogin, setIsLogin] = useState(true); const [email, setEmail] = useState(""); + const [name, setName] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -26,6 +27,7 @@ export function AuthForm({ onAuthSuccess }: AuthFormProps) { } else { await authClient.signUp.email({ email, + name, password, }); } @@ -54,6 +56,20 @@ export function AuthForm({ onAuthSuccess }: AuthFormProps) { /> + {!isLogin && ( +
+ + setName(e.target.value)} + required + style={{ width: "100%", padding: "8px", marginTop: "5px" }} + /> +
+ )} +
("http://localhost:8080/registry"); +const client = createClient("http://localhost:8080/registry"); const { useActor } = createRivetKit(client); @@ -30,7 +30,13 @@ export function ChatRoom({ user, onSignOut }: ChatRoomProps) { // Listen for new messages chatRoom.useEvent("newMessage", (newMessage) => { - setMessages(prev => [...prev, newMessage]); + setMessages(prev => [...prev, newMessage as { + id: string; + userId: string; + username: string; + message: string; + timestamp: number; + }]); }); // Load initial messages when connected diff --git a/packages/core/src/actor/errors.ts b/packages/core/src/actor/errors.ts index d474334a3..7503ab1f7 100644 --- a/packages/core/src/actor/errors.ts +++ b/packages/core/src/actor/errors.ts @@ -182,7 +182,7 @@ export class InvalidStateType extends ActorError { } else { msg += "Attempted to set invalid state."; } - msg += " State must be JSON serializable."; + msg += " State must be CBOR serializable. Valid types include: null, undefined, boolean, string, number, BigInt, Date, RegExp, Error, typed arrays (Uint8Array, Int8Array, Float32Array, etc.), Map, Set, Array, and plain objects."; super("invalid_state_type", msg); } } diff --git a/packages/core/src/actor/instance.ts b/packages/core/src/actor/instance.ts index 5cf1ec9ba..640b59dbb 100644 --- a/packages/core/src/actor/instance.ts +++ b/packages/core/src/actor/instance.ts @@ -3,7 +3,7 @@ import type * as wsToClient from "@/actor/protocol/message/to-client"; import type * as wsToServer from "@/actor/protocol/message/to-server"; import type { Client } from "@/client/client"; import type { Logger } from "@/common/log"; -import { isJsonSerializable, stringifyError } from "@/common/utils"; +import { isCborSerializable, stringifyError } from "@/common/utils"; import type { Registry } from "@/mod"; import invariant from "invariant"; import onChange from "on-change"; @@ -454,7 +454,7 @@ export class ActorInstance { if (target === null || typeof target !== "object") { let invalidPath = ""; if ( - !isJsonSerializable( + !isCborSerializable( target, (path) => { invalidPath = path; @@ -479,7 +479,7 @@ export class ActorInstance { (path: string, value: any, _previousValue: any, _applyData: any) => { let invalidPath = ""; if ( - !isJsonSerializable( + !isCborSerializable( value, (invalidPathPart) => { invalidPath = invalidPathPart; diff --git a/packages/core/src/common/utils.ts b/packages/core/src/common/utils.ts index 1c6e61b6a..d56bf1a8d 100644 --- a/packages/core/src/common/utils.ts +++ b/packages/core/src/common/utils.ts @@ -37,10 +37,13 @@ export function safeStringify(obj: unknown, maxSize: number) { // it. Roll back state if fails to serialize. /** - * Check if a value is JSON serializable. + * Check if a value is CBOR serializable. * Optionally pass an onInvalid callback to receive the path to invalid values. + * + * For a complete list of supported CBOR tags, see: + * https://github.com/kriszyp/cbor-x/blob/cc1cf9df8ba72288c7842af1dd374d73e34cdbc1/README.md#list-of-supported-tags-for-decoding */ -export function isJsonSerializable( +export function isCborSerializable( value: unknown, onInvalid?: (path: string) => void, currentPath = "", @@ -62,30 +65,98 @@ export function isJsonSerializable( return true; } + // Handle BigInt (CBOR tags 2 and 3) + if (typeof value === "bigint") { + return true; + } + + // Handle Date objects (CBOR tags 0 and 1) + if (value instanceof Date) { + return true; + } + + // Handle typed arrays (CBOR tags 64-82) + if ( + value instanceof Uint8Array || + value instanceof Uint8ClampedArray || + value instanceof Uint16Array || + value instanceof Uint32Array || + value instanceof BigUint64Array || + value instanceof Int8Array || + value instanceof Int16Array || + value instanceof Int32Array || + value instanceof BigInt64Array || + value instanceof Float32Array || + value instanceof Float64Array + ) { + return true; + } + + // Handle Map (CBOR tag 259) + if (value instanceof Map) { + for (const [key, val] of value.entries()) { + const keyPath = currentPath ? `${currentPath}.key(${String(key)})` : `key(${String(key)})`; + const valPath = currentPath ? `${currentPath}.value(${String(key)})` : `value(${String(key)})`; + if (!isCborSerializable(key, onInvalid, keyPath) || !isCborSerializable(val, onInvalid, valPath)) { + return false; + } + } + return true; + } + + // Handle Set (CBOR tag 258) + if (value instanceof Set) { + let index = 0; + for (const item of value.values()) { + const itemPath = currentPath ? `${currentPath}.set[${index}]` : `set[${index}]`; + if (!isCborSerializable(item, onInvalid, itemPath)) { + return false; + } + index++; + } + return true; + } + + // Handle RegExp (CBOR tag 27) + if (value instanceof RegExp) { + return true; + } + + // Handle Error objects (CBOR tag 27) + if (value instanceof Error) { + return true; + } + // Handle arrays if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { const itemPath = currentPath ? `${currentPath}[${i}]` : `[${i}]`; - if (!isJsonSerializable(value[i], onInvalid, itemPath)) { + if (!isCborSerializable(value[i], onInvalid, itemPath)) { return false; } } return true; } - // Handle plain objects + // Handle plain objects and records (CBOR tags 105, 51, 57344-57599) if (typeof value === "object") { - // Reject if it's not a plain object - if (Object.getPrototypeOf(value) !== Object.prototype) { - onInvalid?.(currentPath); - return false; + // Allow plain objects and objects with prototypes (for records and named objects) + const proto = Object.getPrototypeOf(value); + if (proto !== null && proto !== Object.prototype) { + // Check if it's a known serializable object type + const constructor = value.constructor; + if (constructor && typeof constructor.name === "string") { + // Allow objects with named constructors (records, named objects) + // This includes user-defined classes and built-in objects + // that CBOR can serialize with tag 27 or record tags + } } // Check all properties recursively for (const key in value) { const propPath = currentPath ? `${currentPath}.${key}` : key; if ( - !isJsonSerializable( + !isCborSerializable( value[key as keyof typeof value], onInvalid, propPath, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 919477b5e..36b1918d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: better-auth: specifier: ^1.0.1 version: 1.2.10 + better-sqlite3: + specifier: ^9.4.3 + version: 9.6.0 hono: specifier: ^4.7.0 version: 4.8.0 @@ -70,6 +73,9 @@ importers: '@rivetkit/actor': specifier: workspace:* version: link:../../packages/actor + '@types/better-sqlite3': + specifier: ^7.6.9 + version: 7.6.13 '@types/node': specifier: ^22.13.9 version: 22.15.32 @@ -2231,6 +2237,9 @@ packages: better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + better-sqlite3@9.6.0: + resolution: {integrity: sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -5597,6 +5606,11 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + better-sqlite3@9.6.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0