diff --git a/.env b/.env index 3798ceb..6152c20 100644 --- a/.env +++ b/.env @@ -3,3 +3,5 @@ ZERO_UPSTREAM_DB="postgresql://user:password@127.0.0.1:5430/postgres" ZERO_AUTH_SECRET="secretkey" ZERO_REPLICA_FILE="/tmp/hello_zero_replica.db" ZERO_LOG_LEVEL="debug" +ZERO_GET_QUERIES_URL="http://localhost:5173/api/get-queries" +ZERO_MUTATE_URL="http://localhost:5173/api/push" diff --git a/README.md b/README.md index c64085d..0e25f40 100644 --- a/README.md +++ b/README.md @@ -108,19 +108,35 @@ createRoot(document.getElementById("root")!).render( ); ``` -4. **Using Zero in Components** Example usage in React components. See +4. **Set up saved queries** See [queries.ts](src/queries.ts): + +```typescript +import { savedQuery } from "@rocicorp/zero"; +import { schema } from "./schema"; + +const builder = createBuilder(schema); + +export const queries = { + users: savedQuery('users', z.tuple([]), () => { + return builder.user.orderBy('name', 'asc'); + } +}; +``` + +5. **Using Zero in Components** Example usage in React components. See [App.tsx](src/App.tsx): ```typescript import { useQuery, useZero } from "@rocicorp/zero/react"; import { Schema } from "./schema"; +import { queries } from "./queries"; // You may want to put this in its own file const useZ = useZero; export function UsersPage() { const z = useZ(); - const users = useQuery(z.query.user); + const [users] = useQuery(queries.users()); if (!users) { return null; @@ -138,7 +154,8 @@ export function UsersPage() { ``` For more examples of queries, mutations, and relationships, explore the -[App.tsx](src/App.tsx) file in this repository. +[queries.ts](src/queries.ts) and [mutators.ts](src/mutators.ts) files in this +repository. ### Optional: Authentication diff --git a/api/index.ts b/api/index.ts index 47e8abb..0152d15 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,12 +1,26 @@ import { Hono } from "hono"; import { setCookie } from "hono/cookie"; import { handle } from "hono/vercel"; -import { SignJWT } from "jose"; +import { SignJWT, jwtVerify } from "jose"; +import { Pool } from "pg"; +import { withValidation, ReadonlyJSONValue } from "@rocicorp/zero"; +import { PushProcessor } from "@rocicorp/zero/server"; +import { zeroNodePg } from "@rocicorp/zero/server/adapters/pg"; +import { handleGetQueriesRequest } from "@rocicorp/zero/server"; +import { AuthData, schema } from "../src/schema"; +import { createMutators } from "../src/mutators"; +import { queries, allUsers } from "../src/queries"; export const config = { runtime: "edge", }; +const pool = new Pool({ + connectionString: process.env.ZERO_UPSTREAM_DB, +}); + +const pushProcessor = new PushProcessor(zeroNodePg(schema, pool)); + export const app = new Hono().basePath("/api"); // See seed.sql @@ -45,6 +59,71 @@ app.get("/login", async (c) => { return c.text("ok"); }); +async function getAuthData(request: Request): Promise { + const authHeader = request.headers.get("Authorization"); + const token = authHeader?.replace("Bearer ", ""); + + if (!token) { + return undefined; + } + + try { + const { payload } = await jwtVerify( + token, + new TextEncoder().encode(must(process.env.ZERO_AUTH_SECRET)), + ); + return { sub: payload.sub as string | null }; + } catch { + return undefined; + } +} + + +// Get Queries endpoint for synced queries +app.post("/get-queries", async (c) => { + const authData = await getAuthData(c.req.raw); + + return c.json( + await handleGetQueriesRequest( + (name, args) => getQuery(authData, name, args), + schema, + c.req.raw, + ), + ); +}); + +const validated = Object.fromEntries( + Object.values(queries).map((q) => [q.queryName, withValidation(q)]) +); + +function getQuery( + authData: AuthData | undefined, + name: string, + args: readonly ReadonlyJSONValue[], +) { + const q = validated[name]; + if (!q) { + throw new Error(`No such query: ${name}`); + } + // withValidation returns a function that takes (context, ...args) + // and returns a Query. We need to return { query: Query } + return { + query: q(authData, ...args), + }; +} + +// Push endpoint for custom mutators +app.post("/push", async (c) => { + const authData = await getAuthData(c.req.raw); + + const result = await pushProcessor.process( + createMutators(authData), + c.req.raw, + ); + + return c.json(result); +}); + export default handle(app); function must(val: T) { diff --git a/package.json b/package.json index a70d71d..baebfa0 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,11 @@ "@rocicorp/zero": "0.24.2025101500", "jose": "^5.9.6", "js-cookie": "^3.0.5", + "pg": "^8.16.3", "react": "^18.3.1", "react-dom": "^18.3.1", - "sst": "3.9.33" + "sst": "3.9.33", + "zod": "^4.1.12" }, "devDependencies": { "@eslint/js": "^9.9.0", diff --git a/src/App.tsx b/src/App.tsx index c46ad69..428440e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,3 @@ -import { escapeLike } from "@rocicorp/zero"; import { useQuery, useZero } from "@rocicorp/zero/react"; import Cookies from "js-cookie"; import { useState } from "react"; @@ -6,33 +5,21 @@ import { formatDate } from "./date"; import { randInt } from "./rand"; import { RepeatButton } from "./repeat-button"; import { Schema } from "./schema"; +import { Mutators } from "./mutators"; import { randomMessage } from "./test-data"; +import { queries } from "./queries"; function App() { - const z = useZero(); - const [users] = useQuery(z.query.user); - const [mediums] = useQuery(z.query.medium); + const z = useZero(); + + const [users] = useQuery(queries.users()); + const [mediums] = useQuery(queries.mediums()); + const [allMessages] = useQuery(queries.messages()); const [filterUser, setFilterUser] = useState(""); const [filterText, setFilterText] = useState(""); - const all = z.query.message; - const [allMessages] = useQuery(all); - - let filtered = all - .related("medium") - .related("sender") - .orderBy("timestamp", "desc"); - - if (filterUser) { - filtered = filtered.where("senderID", filterUser); - } - - if (filterText) { - filtered = filtered.where("body", "LIKE", `%${escapeLike(filterText)}%`); - } - - const [filteredMessages] = useQuery(filtered); + const [filteredMessages] = useQuery(queries.filteredMessages(filterUser, filterText)); const hasFilters = filterUser || filterText; diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..73a1d36 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,25 @@ +import { jwtVerify } from "jose"; +import { AuthData } from "./schema"; + +export async function getAuthData(token: string | undefined): Promise { + if (!token) { + return undefined; + } + + try { + const { payload } = await jwtVerify( + token, + new TextEncoder().encode(must(process.env.ZERO_AUTH_SECRET)), + ); + return { sub: payload.sub as string | null }; + } catch { + return undefined; + } +} + +function must(val: T) { + if (!val) { + throw new Error("Expected value to be defined"); + } + return val; +} diff --git a/src/main.tsx b/src/main.tsx index bfe19f4..371d378 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import App from "./App.tsx"; import "./index.css"; import { ZeroProvider } from "@rocicorp/zero/react"; import { schema } from "./schema.ts"; +import { createMutators } from "./mutators.ts"; import Cookies from "js-cookie"; import { decodeJwt } from "jose"; @@ -13,9 +14,18 @@ const userID = decodedJWT?.sub ? (decodedJWT.sub as string) : "anon"; const server = import.meta.env.VITE_PUBLIC_SERVER; const auth = encodedJWT; +// Create auth data for mutators +const authData = decodedJWT?.sub ? { sub: decodedJWT.sub as string } : { sub: null }; + createRoot(document.getElementById("root")!).render( - + diff --git a/src/mutators.ts b/src/mutators.ts new file mode 100644 index 0000000..f0aebb6 --- /dev/null +++ b/src/mutators.ts @@ -0,0 +1,69 @@ +import { Transaction } from "@rocicorp/zero"; +import { type AuthData, Schema } from "./schema"; + +export function createMutators(authData?: AuthData) { + return { + message: { + insert: async ( + tx: Transaction, + args: { + id: string; + senderID: string; + mediumID: string; + body: string; + labels: string[]; + timestamp: number; + }, + ) => { + // Anyone can insert messages + await tx.mutate.message.insert(args); + }, + + update: async ( + tx: Transaction, + args: { + id: string; + body: string; + }, + ) => { + const existing = await tx.query.message + .where("id", args.id) + .one() + .run(); + + if (!existing) { + throw new Error("Message not found"); + } + + // Validate (on both client and server) + if (existing.senderID !== authData?.sub) { + throw new Error("Only the sender can edit this message"); + } + + // Server-only validation + if (tx.location === "server") { + if (args.body.length > 1000) { + throw new Error("Message body too long (max 1000 characters)"); + } + } + + await tx.mutate.message.update(args); + }, + + delete: async ( + tx: Transaction, + args: { + id: string; + }, + ) => { + if (!authData?.sub) { + throw new Error("Must be logged in to delete messages"); + } + + await tx.mutate.message.delete(args); + }, + }, + } as const; +} + +export type Mutators = ReturnType; diff --git a/src/queries.ts b/src/queries.ts new file mode 100644 index 0000000..9b3b79e --- /dev/null +++ b/src/queries.ts @@ -0,0 +1,53 @@ +import { syncedQuery, createBuilder, escapeLike } from "@rocicorp/zero"; +import { z } from "zod"; +import { schema } from "./schema"; + +export const builder = createBuilder(schema); + +export const queries = { + users: syncedQuery( + "users", + z.tuple([]), + () => { + return builder.user.orderBy("name", "asc"); + } + ), + mediums: syncedQuery( + "mediums", + z.tuple([]), + () => { + return builder.medium.orderBy("name", "asc"); + } + ), + messages: syncedQuery( + "messages", + z.tuple([]), + () => { + return builder.message.orderBy("timestamp", "desc"); + } + ), + filteredMessages: syncedQuery( + "filteredMessages", + z.tuple([ + z.string().optional(), // filterUser + z.string().optional(), // filterText + ]), + (filterUser, filterText) => { + let query = builder.message + .related("medium") + .related("sender") + .orderBy("timestamp", "desc"); + + if (filterUser) { + query = query.where("senderID", filterUser); + } + + if (filterText) { + // Note: LIKE is available in ZQL + query = query.where("body", "LIKE", `%${escapeLike(filterText)}%`); + } + + return query; + } + ) +}; diff --git a/src/schema.ts b/src/schema.ts index 85f2a8f..de7158d 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -8,15 +8,12 @@ import { createSchema, definePermissions, - ExpressionBuilder, - Row, - ANYONE_CAN, table, string, boolean, number, relationships, - PermissionsConfig, + Row, json, } from "@rocicorp/zero"; @@ -62,6 +59,9 @@ const messageRelationships = relationships(message, ({ one }) => ({ export const schema = createSchema({ tables: [user, medium, message], relationships: [messageRelationships], + // Disable legacy APIs - exclusively use custom mutators and synced queries + enableLegacyMutators: false, + enableLegacyQueries: false, }); export type Schema = typeof schema; @@ -70,47 +70,11 @@ export type Medium = Row; export type User = Row; // The contents of your decoded JWT. -type AuthData = { +export type AuthData = { sub: string | null; }; -export const permissions = definePermissions(schema, () => { - const allowIfLoggedIn = ( - authData: AuthData, - { cmpLit }: ExpressionBuilder - ) => cmpLit(authData.sub, "IS NOT", null); - - const allowIfMessageSender = ( - authData: AuthData, - { cmp }: ExpressionBuilder - ) => cmp("senderID", "=", authData.sub ?? ""); - - return { - medium: { - row: { - select: ANYONE_CAN, - }, - }, - user: { - row: { - select: ANYONE_CAN, - }, - }, - message: { - row: { - // anyone can insert - insert: ANYONE_CAN, - update: { - // sender can only edit own messages - preMutation: [allowIfMessageSender], - // sender can only edit messages to be owned by self - postMutation: [allowIfMessageSender], - }, - // must be logged in to delete - delete: [allowIfLoggedIn], - // everyone can read current messages - select: ANYONE_CAN, - }, - }, - } satisfies PermissionsConfig; +// TODO: Zero requires an empty permissions object even if we're not using them :( +export const permissions = definePermissions(schema, () => { + return {}; });