From 02efc3e6a0b27b65bb6c9128431dd8ee8d00d4c9 Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Thu, 9 Jul 2026 17:12:57 -0500 Subject: [PATCH] Replace Core UI server actions with Better Call API Co-authored-by: Cursor --- apps/test-web/README.md | 18 +- apps/test-web/app/_hackkit/server-actions.ts | 173 --------- apps/test-web/app/_hackkit/ui-action-map.ts | 61 ---- .../app/api/hackkit/[...all]/route.ts | 7 + apps/test-web/app/providers.tsx | 14 +- apps/test-web/lib/runtime.ts | 2 + packages/next/README.md | 49 ++- packages/next/package.json | 12 +- .../next/src/__tests__/action-bridge.test.ts | 46 +-- packages/next/src/__tests__/api.test.ts | 116 ++++++ packages/next/src/__tests__/client.test.ts | 42 +++ .../next/src/__tests__/next-handler.test.ts | 16 + packages/next/src/action-map.ts | 63 ---- packages/next/src/actions.ts | 164 --------- packages/next/src/api.ts | 342 ++++++++++++++++++ packages/next/src/endpoints.ts | 32 ++ packages/next/src/index.ts | 10 +- packages/next/src/next-handler.ts | 18 + packages/next/src/runtime.ts | 23 ++ packages/next/src/ui-actions-client.ts | 84 +++++ pnpm-lock.yaml | 6 + 21 files changed, 792 insertions(+), 506 deletions(-) delete mode 100644 apps/test-web/app/_hackkit/server-actions.ts delete mode 100644 apps/test-web/app/_hackkit/ui-action-map.ts create mode 100644 apps/test-web/app/api/hackkit/[...all]/route.ts create mode 100644 packages/next/src/__tests__/api.test.ts create mode 100644 packages/next/src/__tests__/client.test.ts create mode 100644 packages/next/src/__tests__/next-handler.test.ts delete mode 100644 packages/next/src/action-map.ts delete mode 100644 packages/next/src/actions.ts create mode 100644 packages/next/src/api.ts create mode 100644 packages/next/src/endpoints.ts create mode 100644 packages/next/src/next-handler.ts create mode 100644 packages/next/src/ui-actions-client.ts diff --git a/apps/test-web/README.md b/apps/test-web/README.md index 80c65efd..3e2fecdf 100644 --- a/apps/test-web/README.md +++ b/apps/test-web/README.md @@ -178,9 +178,21 @@ pnpm --filter test-web build CI also runs package typechecks and builds for `@hackkit/core`, `@hackkit/config`, `@hackkit/next`, `@hackkit/ui`, `@hackkit/plugin-teams`, `@hackkit/plugin-discord`, and `@hackkit/plugin-notifications-email`. -## Server Actions - -UI mutations live on the Next runtime (`runtime.mutations` from `createHackKitMutations`). Named server actions and the `hackKitUIActions` provider map ship from `@hackkit/next` — [`app/providers.tsx`](app/providers.tsx) passes `hackKitUIActions` to `HackKitUIProvider`. Plugin actions remain generated in [`app/hackkit-plugin-actions.ts`](app/hackkit-plugin-actions.ts) by `hackkit plugin sync`. +## Core UI API boundary + +Core UI mutations are served by the static Better Call registry on +`/api/hackkit`; [`app/api/hackkit/[...all]/route.ts`](app/api/hackkit/[...all]/route.ts) +is only a Web Request → Next route adapter. The client-side +[`app/providers.tsx`](app/providers.tsx) creates the typed +`HackKitUIActions` client from `@hackkit/next/client`, so there are no +app-local Core Server Actions or action maps. + +The API resolves Better Auth sessions from the incoming Request headers. +Cookie-authenticated JSON mutations require a same-origin `Origin` header and +continue through the existing Core permission checks. Better Auth itself stays +on its separate `/api/auth/[...all]` route. Plugin actions remain generated in +[`app/hackkit-plugin-actions.ts`](app/hackkit-plugin-actions.ts) by +`hackkit plugin sync`; they are deliberately outside the Core UI API registry. ## Configuration diff --git a/apps/test-web/app/_hackkit/server-actions.ts b/apps/test-web/app/_hackkit/server-actions.ts deleted file mode 100644 index 556671fb..00000000 --- a/apps/test-web/app/_hackkit/server-actions.ts +++ /dev/null @@ -1,173 +0,0 @@ -"use server"; - -import type { HackKitUIActions } from "@hackkit/ui"; -import { getRuntime } from "@/lib/runtime"; - -/** - * App-owned Server Action boundary. - * - * A Server Action request can execute without evaluating a page or layout - * module first, so each action resolves the app runtime explicitly before - * delegating to the canonical mutations from @hackkit/next. - */ -async function callMutation( - name: K, - ...args: Parameters -): Promise>> { - const mutations = (await getRuntime()).mutations; - return ( - mutations[name] as ( - ...actionArgs: typeof args - ) => ReturnType - )(...args) as Awaited>; -} - -export async function completeUserData( - ...args: Parameters -) { - return callMutation("completeUserData", ...args); -} - -export async function claimHackTag( - ...args: Parameters -) { - return callMutation("claimHackTag", ...args); -} - -export async function updateUserProfile( - ...args: Parameters -) { - return callMutation("updateUserProfile", ...args); -} - -export async function registerHacker( - ...args: Parameters -) { - return callMutation("registerHacker", ...args); -} - -export async function createEvent( - ...args: Parameters -) { - return callMutation("createEvent", ...args); -} - -export async function updateEvent( - ...args: Parameters -) { - return callMutation("updateEvent", ...args); -} - -export async function deleteEvent( - ...args: Parameters -) { - return callMutation("deleteEvent", ...args); -} - -export async function previewEventPassQr( - ...args: Parameters -) { - return callMutation("previewEventPassQr", ...args); -} - -export async function recordEventScan( - ...args: Parameters -) { - return callMutation("recordEventScan", ...args); -} - -export async function checkInUser( - ...args: Parameters -) { - return callMutation("checkInUser", ...args); -} - -export async function clearCheckIn( - ...args: Parameters -) { - return callMutation("clearCheckIn", ...args); -} - -export async function confirmRsvp( - ...args: Parameters -) { - return callMutation("confirmRsvp", ...args); -} - -export async function cancelRsvp( - ...args: Parameters -) { - return callMutation("cancelRsvp", ...args); -} - -export async function setRsvpStatus( - ...args: Parameters -) { - return callMutation("setRsvpStatus", ...args); -} - -export async function promoteRsvp( - ...args: Parameters -) { - return callMutation("promoteRsvp", ...args); -} - -export async function approveUser( - ...args: Parameters -) { - return callMutation("approveUser", ...args); -} - -export async function banUser( - ...args: Parameters -) { - return callMutation("banUser", ...args); -} - -export async function unbanUser( - ...args: Parameters -) { - return callMutation("unbanUser", ...args); -} - -export async function assignRoleToUser( - ...args: Parameters -) { - return callMutation("assignRoleToUser", ...args); -} - -export async function createRole( - ...args: Parameters -) { - return callMutation("createRole", ...args); -} - -export async function updateRole( - ...args: Parameters -) { - return callMutation("updateRole", ...args); -} - -export async function deleteRole( - ...args: Parameters -) { - return callMutation("deleteRole", ...args); -} - -export async function listSettings( - ...args: Parameters -) { - return callMutation("listSettings", ...args); -} - -export async function setSettings( - ...args: Parameters -) { - return callMutation("setSettings", ...args); -} - -export async function resetSetting( - ...args: Parameters -) { - return callMutation("resetSetting", ...args); -} diff --git a/apps/test-web/app/_hackkit/ui-action-map.ts b/apps/test-web/app/_hackkit/ui-action-map.ts deleted file mode 100644 index e01fd82c..00000000 --- a/apps/test-web/app/_hackkit/ui-action-map.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { HackKitUIActions } from "@hackkit/ui"; -import { - approveUser, - assignRoleToUser, - banUser, - cancelRsvp, - checkInUser, - claimHackTag, - clearCheckIn, - completeUserData, - confirmRsvp, - createEvent, - createRole, - deleteEvent, - deleteRole, - listSettings, - previewEventPassQr, - promoteRsvp, - recordEventScan, - registerHacker, - resetSetting, - setRsvpStatus, - setSettings, - unbanUser, - updateEvent, - updateRole, - updateUserProfile, -} from "./server-actions"; - -/** - * Serializable action references supplied to the client-side UI provider. - * Keep this separate from the "use server" module because that module may only - * export async Server Action functions. - */ -export const hackKitUIActions = { - completeUserData, - claimHackTag, - updateUserProfile, - registerHacker, - createEvent, - updateEvent, - deleteEvent, - previewEventPassQr, - recordEventScan, - checkInUser, - clearCheckIn, - confirmRsvp, - cancelRsvp, - setRsvpStatus, - promoteRsvp, - approveUser, - banUser, - unbanUser, - assignRoleToUser, - createRole, - updateRole, - deleteRole, - listSettings, - setSettings, - resetSetting, -} satisfies HackKitUIActions; diff --git a/apps/test-web/app/api/hackkit/[...all]/route.ts b/apps/test-web/app/api/hackkit/[...all]/route.ts new file mode 100644 index 00000000..3fd68e9c --- /dev/null +++ b/apps/test-web/app/api/hackkit/[...all]/route.ts @@ -0,0 +1,7 @@ +import { toNextJsHandler } from "@hackkit/next"; +import { getRuntime } from "@/lib/runtime"; + +const handler = async (request: Request) => (await getRuntime()).api.handler(request); + +export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = + toNextJsHandler(handler); diff --git a/apps/test-web/app/providers.tsx b/apps/test-web/app/providers.tsx index a810728a..f6c076a6 100644 --- a/apps/test-web/app/providers.tsx +++ b/apps/test-web/app/providers.tsx @@ -1,20 +1,22 @@ +"use client"; + import { HackKitUIProvider } from "@hackkit/ui"; import type * as React from "react"; -import { hackKitUIActions } from "./_hackkit/ui-action-map"; +import { createHackkitUIActionsClient } from "@hackkit/next/client"; /** - * Wires Core mutations through HackKit actions. Those actions import the app - * runtime before resolving a mutation, which is required for Server - * Action requests that do not execute a page module first. + * Core UI actions use the typed Better Call client. Authentication stays in + * the incoming cookie request handled by `/api/hackkit`; no Server Action + * references or app-maintained action map are serialized to the browser. * * Do not pass `DEFAULT_HACKKIT_UI_ROUTES` (or any map with route builder - * functions) from this Server Component — those builders are not serializable. + * functions) through this Client Component — those builders are not serializable. * Omit `routes` to use the client-side defaults, or pass only string overrides. * For custom admin builders, supply a client `navigation` adapter instead. */ export function Providers({ children }: { children: React.ReactNode }) { return ( - + {children} ); diff --git a/apps/test-web/lib/runtime.ts b/apps/test-web/lib/runtime.ts index 962256dc..99ebcac3 100644 --- a/apps/test-web/lib/runtime.ts +++ b/apps/test-web/lib/runtime.ts @@ -15,6 +15,8 @@ const runtimePromise = createHackkitRuntimeFromConfig({ config: appConfig, database: db, auth: betterAuthAdapter({ auth, logger: getAppLogger() }), + resolveSession: (requestHeaders) => + auth.api.getSession({ headers: requestHeaders }), afterCurrentUser: async (user, hackkit) => { await provisionOwnerFromAllowlist(hackkit, user); }, diff --git a/packages/next/README.md b/packages/next/README.md index f0641a5e..0ecee489 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -1,6 +1,7 @@ # `@hackkit/next` -Next.js integration for HackKit Web Apps. This package is the single composition root for runtime setup, page guards, Core mutations, and HackKit UI provider actions. +Next.js integration for HackKit Web Apps. This package composes the runtime, +page guards, and a Web-standard Better Call API for all Core UI actions. Canonical walkthrough: [`docs/guides/next-integration.md`](../../docs/guides/next-integration.md). Reference app: [`apps/test-web`](../../apps/test-web). @@ -22,6 +23,7 @@ const runtimePromise = createHackkitRuntimeFromConfig({ config: appConfig, database: db, auth: authAdapter, + resolveSession: (headers) => auth.api.getSession({ headers }), afterCurrentUser: async (user, hackkit) => { // optional app-specific work (e.g. owner allowlist) }, @@ -41,27 +43,50 @@ export async function getPageGuards() { `createHackkitRuntime` / `createHackkitRuntimeFromConfig` owns: - HackKit Core init -- `mutations` via `createHackKitMutations` +- `mutations` via `createHackKitMutations` for server-side composition +- `api`, a static Better Call registry with `api.handler(request)` and typed + direct endpoint methods - `pageGuards` via `createPageGuards` (with runtime `getCurrentUser` / settings cache) - Auth helpers (`getAuthId`, `getCurrentUser`, `getSettingValue`) -### 2. Pass `hackKitUIActions` to HackKit UI +### 2. Mount the API and use its client + +```tsx +// app/api/hackkit/[...all]/route.ts +import { toNextJsHandler } from "@hackkit/next"; +import { getRuntime } from "@/lib/runtime"; + +const handler = async (request: Request) => + (await getRuntime()).api.handler(request); +export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = + toNextJsHandler(handler); +``` ```tsx // app/providers.tsx -import { hackKitUIActions } from "@hackkit/next"; +"use client"; +import { createHackkitUIActionsClient } from "@hackkit/next/client"; import { HackKitUIProvider } from "@hackkit/ui"; export function Providers({ children }: { children: React.ReactNode }) { return ( - + {children} ); } ``` -Do not hand-maintain a local Core action map. Named server actions live inside `@hackkit/next` and delegate to `runtime.mutations`. +Do not hand-maintain an app action map. The browser client structurally +fulfills `HackKitUIActions` and routes every Core operation to +`/api/hackkit`. Keep Better Auth's `/api/auth/[...all]` route separate. + +The API only accepts JSON mutations from its own origin by default. It resolves +the Better Auth session from the incoming request headers and then calls the +existing Core mutation/domain methods, so authorization checks remain in force. +Domain failures retain the existing `{ ok: false, message }` UI contract; +authentication, origin/CSRF, and validation failures use HTTP 401, 403, and +400 responses respectively. Do not pass route maps that contain builder functions from a Server Component — they are not serializable. Omit `routes` to use client-side defaults, pass only string overrides, or supply a client `navigation` adapter for custom admin builders. Server-only helpers (for example onboarding step links) may still import `DEFAULT_HACKKIT_UI_ROUTES` / an app route map. @@ -91,8 +116,9 @@ Plugin server actions stay generated by `hackkit plugin sync` (for example `app/ | Export | Role | | --------------------------------------------------------- | ----------------------------------------------------- | | `createHackkitRuntime` / `createHackkitRuntimeFromConfig` | Composition root | -| `setHackkitRuntime` / `getHackkitRuntime` | Module singleton for server actions | -| `hackKitUIActions` | Provider action map (`satisfies HackKitUIActions`) | +| `setHackkitRuntime` / `getHackkitRuntime` | Module singleton for plugin server actions | +| `runtime.api` / `createHackkitApi` | Better Call API registry and Web handler | +| `createHackkitUIActionsClient` (`@hackkit/next/client`) | Typed browser implementation of `HackKitUIActions` | | `createHackKitMutations` | Core → UI mutation bridge (used by the runtime) | | `createPageGuards` | Low-level guard factory (prefer runtime-owned guards) | | `actionSuccess` / `actionFailure` | Shared action result helpers | @@ -101,10 +127,11 @@ Plugin server actions stay generated by `hackkit plugin sync` (for example `app/ 1. Add the method to `HackKitUIActions` in `@hackkit/ui`. 2. Implement it in `createHackKitMutations` (`src/mutations.ts`). -3. Add a thin `"use server"` wrapper in `src/actions.ts`. -4. Include it in `hackKitUIActions` (`src/action-map.ts`). +3. Register its static path, validation schema, and invocation in `src/api.ts`. +4. Add its client call in `src/client.ts`. -App-owned bridge files should not need changes. Package tests assert key parity across the UI contract, mutations, and provider map. +Package tests assert parity across the UI contract, mutations, endpoint +registry, client contract, request-scoped auth, and the Next route adapter. ## Verify diff --git a/packages/next/package.json b/packages/next/package.json index 594980c5..0af7c2b9 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -8,7 +8,13 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./client": { + "types": "./dist/ui-actions-client.d.ts", + "import": "./dist/ui-actions-client.js", + "default": "./dist/ui-actions-client.js" } }, "files": [ @@ -22,7 +28,9 @@ "dependencies": { "@hackkit/config": "workspace:*", "@hackkit/core": "workspace:*", - "@hackkit/ui": "workspace:*" + "@hackkit/ui": "workspace:*", + "better-call": "1.3.5", + "zod": "^4.4.3" }, "peerDependencies": { "next": ">=14", diff --git a/packages/next/src/__tests__/action-bridge.test.ts b/packages/next/src/__tests__/action-bridge.test.ts index 9e10d23d..6ad5748c 100644 --- a/packages/next/src/__tests__/action-bridge.test.ts +++ b/packages/next/src/__tests__/action-bridge.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from "vitest"; import type { HackKitUIActions } from "@hackkit/ui"; -import { hackKitUIActions } from "../action-map"; +import { + createHackkitApi, + HACKKIT_UI_ACTION_ENDPOINTS, +} from "../api"; import { createHackKitMutations } from "../mutations"; -import * as serverActions from "../actions"; /** - * Canonical key list for HackKit UI actions. Extending `HackKitUIActions` - * without updating this array fails typecheck below; omitting a bridge - * export fails the runtime assertions. + * Extending `HackKitUIActions` without registering an API endpoint fails + * typecheck below and the runtime parity assertion. */ const EXPECTED_ACTION_KEYS = [ "completeUserData", @@ -60,8 +61,8 @@ function sortedKeys(value: object): string[] { const expectedSorted = [...EXPECTED_ACTION_KEYS].sort(); describe("Core action bridge", () => { - it("exposes every HackKitUIActions key on hackKitUIActions", () => { - expect(sortedKeys(hackKitUIActions)).toEqual(expectedSorted); + it("registers every HackKitUIActions key as a Better Call endpoint", () => { + expect(sortedKeys(HACKKIT_UI_ACTION_ENDPOINTS)).toEqual(expectedSorted); }); it("exposes every HackKitUIActions key from createHackKitMutations", () => { @@ -73,22 +74,23 @@ describe("Core action bridge", () => { expect(sortedKeys(mutations)).toEqual(expectedSorted); }); - it("exports a named server action for every HackKitUIActions key", () => { - const actionExports = Object.fromEntries( - EXPECTED_ACTION_KEYS.map((key) => { - const value = (serverActions as Record)[key]; - return [key, value]; - }), - ); - for (const key of EXPECTED_ACTION_KEYS) { - expect(typeof actionExports[key], key).toBe("function"); - } - expect(sortedKeys(actionExports)).toEqual(expectedSorted); - }); - - it("wires provider map entries to functions", () => { + it("exposes typed direct API methods for every UI action", () => { + const api = createHackkitApi({ + hackkit: {} as never, + auth: { + getSession: async () => null, + toAuthId: () => "auth-id", + getIdentity: () => ({ + email: "user@example.com", + firstName: "User", + lastName: "Example", + }), + }, + resolveSession: async () => null, + getSettingValue: async () => true, + }); for (const key of EXPECTED_ACTION_KEYS) { - expect(typeof hackKitUIActions[key], key).toBe("function"); + expect(typeof api.api[key], key).toBe("function"); } }); }); diff --git a/packages/next/src/__tests__/api.test.ts b/packages/next/src/__tests__/api.test.ts new file mode 100644 index 00000000..e54e39e5 --- /dev/null +++ b/packages/next/src/__tests__/api.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { createHackkitApi } from "../api"; + +function createApi(session = true) { + const seenHeaders: Headers[] = []; + const api = createHackkitApi({ + hackkit: { + users: { + ensureUser: async () => ({ authId: "request-user" }), + }, + userData: { + completeUserData: async () => undefined, + }, + } as never, + auth: { + getSession: async () => null, + toAuthId: (current) => current.user.id, + getIdentity: () => ({ + email: "user@example.com", + firstName: "User", + lastName: "Example", + }), + }, + resolveSession: async (headers) => { + seenHeaders.push(headers); + return session + ? { + user: { + id: headers.get("x-user") ?? "missing", + email: "user@example.com", + name: "User Example", + }, + } + : null; + }, + getSettingValue: async () => true, + }); + return { api, seenHeaders }; +} + +describe("HackKit Better Call API", () => { + it("resolves the actor from each direct call's headers", async () => { + const { api, seenHeaders } = createApi(); + const result = await api.api.completeUserData({ + headers: new Headers({ "x-user": "request-scoped-user" }), + body: { + age: 20, + gender: "x", + race: "x", + ethnicity: "x", + shirtSize: "m", + dietaryRestrictions: [], + hasAcceptedMLHCodeOfConduct: true, + hasSharedDataWithMLH: true, + isEmailable: true, + }, + }); + + expect(result).toEqual({ ok: true, data: undefined }); + expect(seenHeaders).toHaveLength(1); + expect(seenHeaders[0].get("x-user")).toBe("request-scoped-user"); + }); + + it("returns transport errors for unauthenticated and invalid requests", async () => { + const unauthenticated = createApi(false).api; + const denied = await unauthenticated.handler( + new Request("https://hackkit.test/api/hackkit/complete-user-data", { + method: "POST", + headers: { + origin: "https://hackkit.test", + "content-type": "application/json", + }, + body: JSON.stringify({ + age: 20, + gender: "x", + race: "x", + ethnicity: "x", + shirtSize: "m", + dietaryRestrictions: [], + hasAcceptedMLHCodeOfConduct: true, + hasSharedDataWithMLH: true, + isEmailable: true, + }), + }), + ); + expect(denied.status).toBe(401); + + const { api } = createApi(); + const invalid = await api.handler( + new Request("https://hackkit.test/api/hackkit/complete-user-data", { + method: "POST", + headers: { + origin: "https://hackkit.test", + "content-type": "application/json", + }, + body: JSON.stringify({ age: "not-a-number" }), + }), + ); + expect(invalid.status).toBe(400); + }); + + it("rejects cross-origin cookie mutations", async () => { + const { api } = createApi(); + const response = await api.handler( + new Request("https://hackkit.test/api/hackkit/list-settings", { + method: "POST", + headers: { + origin: "https://attacker.test", + "content-type": "application/json", + }, + body: "{}", + }), + ); + expect(response.status).toBe(403); + }); +}); diff --git a/packages/next/src/__tests__/client.test.ts b/packages/next/src/__tests__/client.test.ts new file mode 100644 index 00000000..c849cf89 --- /dev/null +++ b/packages/next/src/__tests__/client.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHackkitUIActionsClient } from "../ui-actions-client"; + +describe("HackKit API client", () => { + it("fulfills the UI action contract through JSON POST requests", async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true, data: undefined }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const actions = createHackkitUIActionsClient({ fetch }); + + await expect(actions.confirmRsvp()).resolves.toEqual({ + ok: true, + data: undefined, + }); + expect(fetch).toHaveBeenCalledWith( + "/api/hackkit/rsvp/confirm", + expect.objectContaining({ + method: "POST", + credentials: "same-origin", + }), + ); + }); + + it("preserves transport error messages as UI failures", async () => { + const actions = createHackkitUIActionsClient({ + fetch: vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: "Sign in to continue." }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ), + }); + + await expect(actions.confirmRsvp()).resolves.toEqual({ + ok: false, + message: "Sign in to continue.", + }); + }); +}); diff --git a/packages/next/src/__tests__/next-handler.test.ts b/packages/next/src/__tests__/next-handler.test.ts new file mode 100644 index 00000000..bb33e0bb --- /dev/null +++ b/packages/next/src/__tests__/next-handler.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { toNextJsHandler } from "../next-handler"; + +describe("toNextJsHandler", () => { + it("forwards the original Request to every route method", async () => { + const route = toNextJsHandler(async (request) => + Response.json({ pathname: new URL(request.url).pathname }), + ); + const request = new Request("https://hackkit.test/api/hackkit/rsvp/confirm"); + + expect(await (await route.POST(request)).json()).toEqual({ + pathname: "/api/hackkit/rsvp/confirm", + }); + expect(route.POST).toBe(route.PATCH); + }); +}); diff --git a/packages/next/src/action-map.ts b/packages/next/src/action-map.ts deleted file mode 100644 index b6e43e1e..00000000 --- a/packages/next/src/action-map.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { HackKitUIActions } from "@hackkit/ui"; -import { - approveUser, - assignRoleToUser, - banUser, - cancelRsvp, - checkInUser, - claimHackTag, - clearCheckIn, - completeUserData, - confirmRsvp, - createEvent, - createRole, - deleteEvent, - deleteRole, - listSettings, - previewEventPassQr, - promoteRsvp, - recordEventScan, - registerHacker, - resetSetting, - setRsvpStatus, - setSettings, - unbanUser, - updateEvent, - updateRole, - updateUserProfile, -} from "./actions"; - -/** - * HackKit UI actions as a single object for `HackKitUIProvider`. - * - * Named server actions live in `./actions` (a `"use server"` module that - * delegates to `runtime.mutations`). This map is the app-facing wiring - * surface so providers do not hand-maintain the action list. - */ -export const hackKitUIActions = { - completeUserData, - claimHackTag, - updateUserProfile, - registerHacker, - createEvent, - updateEvent, - deleteEvent, - previewEventPassQr, - recordEventScan, - checkInUser, - clearCheckIn, - confirmRsvp, - cancelRsvp, - setRsvpStatus, - promoteRsvp, - approveUser, - banUser, - unbanUser, - assignRoleToUser, - createRole, - updateRole, - deleteRole, - listSettings, - setSettings, - resetSetting, -} satisfies HackKitUIActions; diff --git a/packages/next/src/actions.ts b/packages/next/src/actions.ts deleted file mode 100644 index d2561b3b..00000000 --- a/packages/next/src/actions.ts +++ /dev/null @@ -1,164 +0,0 @@ -"use server"; - -import type { HackKitUIActions } from "@hackkit/ui"; -import { getHackkitRuntime } from "./runtime"; - -async function callMutation( - name: K, - ...args: Parameters -): Promise>> { - const mutations = (await getHackkitRuntime()).mutations; - return (mutations[name] as (...actionArgs: typeof args) => ReturnType< - HackKitUIActions[K] - >)(...args) as Awaited>; -} - -export async function completeUserData( - ...args: Parameters -) { - return callMutation("completeUserData", ...args); -} - -export async function claimHackTag( - ...args: Parameters -) { - return callMutation("claimHackTag", ...args); -} - -export async function updateUserProfile( - ...args: Parameters -) { - return callMutation("updateUserProfile", ...args); -} - -export async function registerHacker( - ...args: Parameters -) { - return callMutation("registerHacker", ...args); -} - -export async function createEvent( - ...args: Parameters -) { - return callMutation("createEvent", ...args); -} - -export async function updateEvent( - ...args: Parameters -) { - return callMutation("updateEvent", ...args); -} - -export async function deleteEvent( - ...args: Parameters -) { - return callMutation("deleteEvent", ...args); -} - -export async function previewEventPassQr( - ...args: Parameters -) { - return callMutation("previewEventPassQr", ...args); -} - -export async function recordEventScan( - ...args: Parameters -) { - return callMutation("recordEventScan", ...args); -} - -export async function checkInUser( - ...args: Parameters -) { - return callMutation("checkInUser", ...args); -} - -export async function clearCheckIn( - ...args: Parameters -) { - return callMutation("clearCheckIn", ...args); -} - -export async function confirmRsvp( - ...args: Parameters -) { - return callMutation("confirmRsvp", ...args); -} - -export async function cancelRsvp( - ...args: Parameters -) { - return callMutation("cancelRsvp", ...args); -} - -export async function setRsvpStatus( - ...args: Parameters -) { - return callMutation("setRsvpStatus", ...args); -} - -export async function promoteRsvp( - ...args: Parameters -) { - return callMutation("promoteRsvp", ...args); -} - -export async function approveUser( - ...args: Parameters -) { - return callMutation("approveUser", ...args); -} - -export async function banUser( - ...args: Parameters -) { - return callMutation("banUser", ...args); -} - -export async function unbanUser( - ...args: Parameters -) { - return callMutation("unbanUser", ...args); -} - -export async function assignRoleToUser( - ...args: Parameters -) { - return callMutation("assignRoleToUser", ...args); -} - -export async function createRole( - ...args: Parameters -) { - return callMutation("createRole", ...args); -} - -export async function updateRole( - ...args: Parameters -) { - return callMutation("updateRole", ...args); -} - -export async function deleteRole( - ...args: Parameters -) { - return callMutation("deleteRole", ...args); -} - -export async function listSettings( - ...args: Parameters -) { - return callMutation("listSettings", ...args); -} - -export async function setSettings( - ...args: Parameters -) { - return callMutation("setSettings", ...args); -} - -export async function resetSetting( - ...args: Parameters -) { - return callMutation("resetSetting", ...args); -} diff --git a/packages/next/src/api.ts b/packages/next/src/api.ts new file mode 100644 index 00000000..733e2558 --- /dev/null +++ b/packages/next/src/api.ts @@ -0,0 +1,342 @@ +import { + APIError, + createEndpoint, + createRouter, +} from "better-call"; +import { z } from "zod"; +import { + CoreSetting, + type AuthAdapter, + type AuthSession, + type HackKit, + type SettingKey, + type SettingValue, + type User, +} from "@hackkit/core"; +import type { + AssignRoleInput, + ApproveUserInput, + BanUserInput, + CheckInUserInput, + CreateRoleInput, + EventFormValues, + HackKitUIActions, + HackTagFormValues, + HackerRegistrationFormValues, + PreviewEventPassQrInput, + RecordEventScanInput, + SetRsvpStatusInput, + SetSettingsInput, + UpdateRoleInput, + UserDataFormValues, + UserProfileFormValues, +} from "@hackkit/ui"; +import { createHackKitMutations } from "./mutations"; +import { + HACKKIT_API_BASE_PATH, + HACKKIT_UI_ACTION_ENDPOINTS, +} from "./endpoints"; + +export { HACKKIT_API_BASE_PATH, HACKKIT_UI_ACTION_ENDPOINTS } from "./endpoints"; + +const authId = z.string().min(1); +const roleId = z.string().min(1); +const eventForm = z.object({ + title: z.string().min(1).max(255), + description: z.string().min(1), + startTime: z.string().min(1), + endTime: z.string().min(1), + location: z.string().min(1).max(255), + type: z.string().min(1), + host: z.string(), + hidden: z.boolean(), +}); +const settingValues = z.array( + z.object({ key: z.string().min(1), value: z.union([z.boolean(), z.number()]) }), +); + +type RequestSessionResolver = (headers: Headers) => Promise; + +export type CreateHackkitApiOptions = { + hackkit: HackKit; + auth: AuthAdapter; + resolveSession: RequestSessionResolver; + getSettingValue: (key: SettingKey) => Promise; + invalidateSettingsCache?: () => void; + afterCurrentUser?: (user: User, hackkit: HackKit) => Promise; + allowedOrigins?: readonly string[]; +}; + +function requireSameOrigin( + request: Request, + allowedOrigins: readonly string[] | undefined, +): void { + if (request.method === "GET" || request.method === "HEAD") return; + const origin = request.headers.get("origin"); + const requestOrigin = new URL(request.url).origin; + if (!origin || !(allowedOrigins ?? [requestOrigin]).includes(origin)) { + throw new APIError("FORBIDDEN", { + code: "CSRF_ORIGIN_MISMATCH", + message: "This request origin is not allowed.", + }); + } +} + +/** + * A static Better Call registry for the Core UI contract. Each endpoint takes + * the incoming request headers explicitly, so cookie sessions never depend on + * Next's ambient request context. + */ +export function createHackkitApi(options: CreateHackkitApiOptions) { + async function getActorAuthId(headers: Headers): Promise { + const session = await options.resolveSession(headers); + if (!session) { + throw new APIError("UNAUTHORIZED", { + code: "UNAUTHENTICATED", + message: "Sign in to continue.", + }); + } + const authId = options.auth.toAuthId(session); + const identity = options.auth.getIdentity(session); + const user = await options.hackkit.users.ensureUser({ authId, ...identity }); + await options.afterCurrentUser?.(user, options.hackkit); + return authId; + } + + async function mutations(headers: Headers) { + const actorAuthId = await getActorAuthId(headers); + return createHackKitMutations({ + hackkit: options.hackkit, + getAuthId: async () => actorAuthId, + getSettingValue: options.getSettingValue, + invalidateSettingsCache: options.invalidateSettingsCache, + }); + } + + function endpoint( + path: string, + body: z.ZodType, + call: (actions: HackKitUIActions, body: Body) => Promise, + ) { + return createEndpoint( + path, + { + method: "POST", + body, + requireHeaders: true, + metadata: { scope: "rpc" }, + }, + async (context) => call(await mutations(context.headers), context.body), + ); + } + + const api = { + completeUserData: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.completeUserData, + z.object({ + age: z.number(), + gender: z.string(), + race: z.string(), + ethnicity: z.string(), + shirtSize: z.string(), + dietaryRestrictions: z.array(z.string()), + accommodationNote: z.string().optional(), + phoneNumber: z.string().optional(), + countryOfResidence: z.string().optional(), + hasAcceptedMLHCodeOfConduct: z.boolean(), + hasSharedDataWithMLH: z.boolean(), + isEmailable: z.boolean(), + }), + (actions, body) => actions.completeUserData(body as UserDataFormValues), + ), + claimHackTag: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.claimHackTag, + z.object({ hackTag: z.string().min(1).max(50) }), + (actions, body) => actions.claimHackTag(body as HackTagFormValues), + ), + updateUserProfile: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.updateUserProfile, + z.object({ + firstName: z.string().min(1).max(100), + lastName: z.string().min(1).max(100), + hackTag: z.string().min(1).max(50), + bio: z.string().max(500).optional(), + pronouns: z.string().max(40).optional(), + skills: z.array(z.string().min(1).max(40)).max(20), + isProfileSearchable: z.boolean(), + discordDisplayHandle: z.string().max(40).optional(), + profilePhotoUrl: z.string().optional(), + }), + (actions, body) => actions.updateUserProfile(body as UserProfileFormValues), + ), + registerHacker: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.registerHacker, + z.object({ + university: z.string().min(1), + major: z.string().min(1), + schoolId: z.string().optional(), + levelOfStudy: z.string().min(1), + hackathonsAttended: z.number().int().nonnegative(), + softwareExperience: z.string().min(1), + heardFrom: z.string().optional(), + githubUrl: z.string().url().optional(), + linkedInUrl: z.string().url().optional(), + personalWebsiteUrl: z.string().url().optional(), + resumeUrl: z.string().min(1).optional(), + }), + (actions, body) => actions.registerHacker(body as HackerRegistrationFormValues), + ), + createEvent: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.createEvent, + eventForm, + (actions, body) => actions.createEvent(body as EventFormValues), + ), + updateEvent: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.updateEvent, + z.object({ eventId: z.string().min(1), values: eventForm }), + (actions, body) => + actions.updateEvent(body.eventId, body.values as EventFormValues), + ), + deleteEvent: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.deleteEvent, + z.object({ eventId: z.string().min(1) }), + (actions, body) => actions.deleteEvent(body.eventId), + ), + previewEventPassQr: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.previewEventPassQr, + z.object({ rawQr: z.string().min(1), eventId: z.string().min(1).optional() }), + (actions, body) => actions.previewEventPassQr(body as PreviewEventPassQrInput), + ), + recordEventScan: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.recordEventScan, + z.object({ rawQr: z.string().min(1), eventId: z.string().min(1) }), + (actions, body) => actions.recordEventScan(body as RecordEventScanInput), + ), + checkInUser: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.checkInUser, + z.object({ rawQr: z.string().min(1) }), + (actions, body) => actions.checkInUser(body as CheckInUserInput), + ), + clearCheckIn: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.clearCheckIn, + z.object({ targetAuthId: authId }), + (actions, body) => actions.clearCheckIn(body.targetAuthId), + ), + confirmRsvp: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.confirmRsvp, + z.object({}), + (actions) => actions.confirmRsvp(), + ), + cancelRsvp: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.cancelRsvp, + z.object({ targetAuthId: authId }), + (actions, body) => actions.cancelRsvp(body.targetAuthId), + ), + setRsvpStatus: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.setRsvpStatus, + z.object({ + targetAuthId: authId, + status: z.enum(["confirmed", "waitlisted", "cancelled"]), + }), + (actions, body) => actions.setRsvpStatus(body as SetRsvpStatusInput), + ), + promoteRsvp: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.promoteRsvp, + z.object({ targetAuthId: authId.optional() }), + (actions, body) => actions.promoteRsvp(body.targetAuthId), + ), + approveUser: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.approveUser, + z.object({ targetAuthId: authId, approved: z.boolean() }), + (actions, body) => actions.approveUser(body as ApproveUserInput), + ), + banUser: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.banUser, + z.object({ targetAuthId: authId, reason: z.string().optional() }), + (actions, body) => actions.banUser(body as BanUserInput), + ), + unbanUser: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.unbanUser, + z.object({ targetAuthId: authId }), + (actions, body) => actions.unbanUser(body.targetAuthId), + ), + assignRoleToUser: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.assignRoleToUser, + z.object({ targetAuthId: authId, roleId }), + (actions, body) => actions.assignRoleToUser(body as AssignRoleInput), + ), + createRole: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.createRole, + z.object({ + id: roleId.optional(), + name: z.string().min(1).max(50), + position: z.number().int().nonnegative(), + permissions: z.array(z.string().min(1)), + color: z.string().optional(), + }), + (actions, body) => actions.createRole(body as CreateRoleInput), + ), + updateRole: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.updateRole, + z.object({ + roleId, + name: z.string().min(1).max(50).optional(), + position: z.number().int().nonnegative().optional(), + permissions: z.array(z.string().min(1)).optional(), + color: z.string().optional(), + }), + (actions, body) => actions.updateRole(body as UpdateRoleInput), + ), + deleteRole: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.deleteRole, + z.object({ roleId }), + (actions, body) => actions.deleteRole(body.roleId), + ), + listSettings: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.listSettings, + z.object({}), + (actions) => actions.listSettings(), + ), + setSettings: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.setSettings, + settingValues, + (actions, body) => actions.setSettings(body as SetSettingsInput), + ), + resetSetting: endpoint( + HACKKIT_UI_ACTION_ENDPOINTS.resetSetting, + z.object({ key: z.string().min(1) }), + (actions, body) => actions.resetSetting(body.key as SettingKey), + ), + } as const; + + function errorResponse(error: unknown): Response { + if (error instanceof APIError) { + return Response.json(error.body ?? { message: error.message }, { + status: error.statusCode, + }); + } + return Response.json( + { message: "The HackKit API could not process this request." }, + { status: 500 }, + ); + } + + const router = createRouter(api, { + basePath: HACKKIT_API_BASE_PATH, + allowedMediaTypes: ["application/json"], + onError: errorResponse, + }); + + return { + api, + router, + handler: async (request: Request) => { + try { + requireSameOrigin(request, options.allowedOrigins); + return await router.handler(request); + } catch (error) { + return errorResponse(error); + } + }, + }; +} diff --git a/packages/next/src/endpoints.ts b/packages/next/src/endpoints.ts new file mode 100644 index 00000000..5a9af4d7 --- /dev/null +++ b/packages/next/src/endpoints.ts @@ -0,0 +1,32 @@ +import type { HackKitUIActions } from "@hackkit/ui"; + +export const HACKKIT_API_BASE_PATH = "/api/hackkit"; + +/** Stable paths for every Core UI action exposed by the Better Call router. */ +export const HACKKIT_UI_ACTION_ENDPOINTS = { + completeUserData: "/complete-user-data", + claimHackTag: "/claim-hack-tag", + updateUserProfile: "/update-user-profile", + registerHacker: "/register-hacker", + createEvent: "/events", + updateEvent: "/events/update", + deleteEvent: "/events/delete", + previewEventPassQr: "/event-pass/preview", + recordEventScan: "/event-pass/scan", + checkInUser: "/check-in", + clearCheckIn: "/check-in/clear", + confirmRsvp: "/rsvp/confirm", + cancelRsvp: "/rsvp/cancel", + setRsvpStatus: "/rsvp/status", + promoteRsvp: "/rsvp/promote", + approveUser: "/users/approval", + banUser: "/users/ban", + unbanUser: "/users/unban", + assignRoleToUser: "/users/role", + createRole: "/roles", + updateRole: "/roles/update", + deleteRole: "/roles/delete", + listSettings: "/settings/list", + setSettings: "/settings", + resetSetting: "/settings/reset", +} as const satisfies Record; diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 9669fa9a..f287a2a5 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -12,6 +12,14 @@ export type { export { actionFailure, actionSuccess } from "@hackkit/ui/actions"; export type { HackKitActionResult } from "@hackkit/ui/actions"; export { createHackKitMutations } from "./mutations"; -export { hackKitUIActions } from "./action-map"; +export { + createHackkitApi, + HACKKIT_API_BASE_PATH, + HACKKIT_UI_ACTION_ENDPOINTS, +} from "./api"; +export type { CreateHackkitApiOptions } from "./api"; +export { toNextJsHandler } from "./next-handler"; +export { createHackkitUIActionsClient } from "./ui-actions-client.js"; +export type { HackkitApiClientOptions } from "./ui-actions-client.js"; export { createPageGuards } from "./page-guards"; export type { PageGuardOptions, PageGuards } from "./page-guards"; diff --git a/packages/next/src/next-handler.ts b/packages/next/src/next-handler.ts new file mode 100644 index 00000000..942051a9 --- /dev/null +++ b/packages/next/src/next-handler.ts @@ -0,0 +1,18 @@ +export type WebHandler = (request: Request) => Promise; + +/** + * Converts a Web-standard handler into the method exports expected by a Next + * Route Handler. It intentionally does not read `next/headers()`: the Request + * is forwarded unchanged to Better Call. + */ +export function toNextJsHandler(handler: WebHandler) { + return { + GET: handler, + POST: handler, + PUT: handler, + PATCH: handler, + DELETE: handler, + HEAD: handler, + OPTIONS: handler, + }; +} diff --git a/packages/next/src/runtime.ts b/packages/next/src/runtime.ts index a9d10549..5bf58571 100644 --- a/packages/next/src/runtime.ts +++ b/packages/next/src/runtime.ts @@ -14,6 +14,7 @@ import type { GroupsInput } from "@hackkit/core"; import type { HackKitLoggerOptions, PermissionKey } from "@hackkit/core"; import type { HackKitUIActions } from "@hackkit/ui"; import { redirect } from "next/navigation"; +import { createHackkitApi, type CreateHackkitApiOptions } from "./api"; import { createHackKitMutations } from "./mutations"; import { createPageGuards, type PageGuards } from "./page-guards"; @@ -38,6 +39,13 @@ export type CreateHackkitRuntimeOptions = { * Runs for both `runtime.getCurrentUser` and the runtime-owned page guards. */ afterCurrentUser?: (user: User, hackkit: HackKit) => Promise; + /** + * Resolves a cookie-authenticated Better Auth session from the headers of + * the API request. This is deliberately separate from the ambient + * `AuthAdapter.getSession()` used by Server Components and page guards. + */ + resolveSession: CreateHackkitApiOptions["resolveSession"]; + allowedApiOrigins?: readonly string[]; }; export type CreateHackkitRuntimeFromConfigOptions = { @@ -45,11 +53,14 @@ export type CreateHackkitRuntimeFromConfigOptions = { database: unknown; auth: AuthAdapter; afterCurrentUser?: CreateHackkitRuntimeOptions["afterCurrentUser"]; + resolveSession: CreateHackkitRuntimeOptions["resolveSession"]; + allowedApiOrigins?: readonly string[]; }; export type HackkitRuntime = { hackkit: HackKit; mutations: HackKitUIActions; + api: ReturnType; pageGuards: PageGuards; getAuthId: () => Promise; getCurrentUser: () => Promise; @@ -118,10 +129,20 @@ export async function createHackkitRuntime( getCurrentUser, getSettingValue, }); + const api = createHackkitApi({ + hackkit, + auth: options.auth, + resolveSession: options.resolveSession, + getSettingValue, + invalidateSettingsCache, + afterCurrentUser: options.afterCurrentUser, + allowedOrigins: options.allowedApiOrigins, + }); return { hackkit, mutations, + api, pageGuards, getAuthId, getCurrentUser, @@ -145,6 +166,8 @@ export function createHackkitRuntimeFromConfig( defaultCompetitorRoleId: config.defaultCompetitorRoleId, seedRoles: config.seedRoles, afterCurrentUser: options.afterCurrentUser, + resolveSession: options.resolveSession, + allowedApiOrigins: options.allowedApiOrigins, }); } diff --git a/packages/next/src/ui-actions-client.ts b/packages/next/src/ui-actions-client.ts new file mode 100644 index 00000000..4d4b52c1 --- /dev/null +++ b/packages/next/src/ui-actions-client.ts @@ -0,0 +1,84 @@ +import type { HackKitActionResult } from "@hackkit/ui/actions"; +import type { HackKitUIActions, SetSettingsInput } from "@hackkit/ui"; +import { + HACKKIT_API_BASE_PATH, + HACKKIT_UI_ACTION_ENDPOINTS, +} from "./endpoints"; + +export type HackkitApiClientOptions = { + baseUrl?: string; + fetch?: typeof globalThis.fetch; +}; + +function transportFailure(error: unknown): HackKitActionResult { + return { + ok: false, + message: + error instanceof Error + ? error.message + : "Could not reach the HackKit API. Please retry.", + }; +} + +/** + * Browser transport for the Core UI contract. The API's domain failures are + * returned unchanged, while authentication, CSRF, validation, and transport + * failures become actionable UI failures. + */ +export function createHackkitUIActionsClient( + options: HackkitApiClientOptions = {}, +): HackKitUIActions { + const baseUrl = options.baseUrl ?? HACKKIT_API_BASE_PATH; + const fetchImpl = options.fetch ?? globalThis.fetch; + + async function post(path: string, body: unknown): Promise> { + try { + const response = await fetchImpl(`${baseUrl}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + credentials: "same-origin", + }); + const payload = (await response.json()) as + | HackKitActionResult + | { message?: string }; + if (response.ok && "ok" in payload) return payload; + return { + ok: false, + message: + ("message" in payload && payload.message) || + `Request failed (${response.status}).`, + }; + } catch (error) { + return transportFailure(error); + } + } + + return { + completeUserData: (values) => post(HACKKIT_UI_ACTION_ENDPOINTS.completeUserData, values), + claimHackTag: (values) => post(HACKKIT_UI_ACTION_ENDPOINTS.claimHackTag, values), + updateUserProfile: (values) => post(HACKKIT_UI_ACTION_ENDPOINTS.updateUserProfile, values), + registerHacker: (values) => post(HACKKIT_UI_ACTION_ENDPOINTS.registerHacker, values), + createEvent: (values) => post(HACKKIT_UI_ACTION_ENDPOINTS.createEvent, values), + updateEvent: (eventId, values) => post(HACKKIT_UI_ACTION_ENDPOINTS.updateEvent, { eventId, values }), + deleteEvent: (eventId) => post(HACKKIT_UI_ACTION_ENDPOINTS.deleteEvent, { eventId }), + previewEventPassQr: (input) => post(HACKKIT_UI_ACTION_ENDPOINTS.previewEventPassQr, input), + recordEventScan: (input) => post(HACKKIT_UI_ACTION_ENDPOINTS.recordEventScan, input), + checkInUser: (input) => post(HACKKIT_UI_ACTION_ENDPOINTS.checkInUser, input), + clearCheckIn: (targetAuthId) => post(HACKKIT_UI_ACTION_ENDPOINTS.clearCheckIn, { targetAuthId }), + confirmRsvp: () => post(HACKKIT_UI_ACTION_ENDPOINTS.confirmRsvp, {}), + cancelRsvp: (targetAuthId) => post(HACKKIT_UI_ACTION_ENDPOINTS.cancelRsvp, { targetAuthId }), + setRsvpStatus: (input) => post(HACKKIT_UI_ACTION_ENDPOINTS.setRsvpStatus, input), + promoteRsvp: (targetAuthId) => post(HACKKIT_UI_ACTION_ENDPOINTS.promoteRsvp, { targetAuthId }), + approveUser: (input) => post(HACKKIT_UI_ACTION_ENDPOINTS.approveUser, input), + banUser: (input) => post(HACKKIT_UI_ACTION_ENDPOINTS.banUser, input), + unbanUser: (targetAuthId) => post(HACKKIT_UI_ACTION_ENDPOINTS.unbanUser, { targetAuthId }), + assignRoleToUser: (input) => post(HACKKIT_UI_ACTION_ENDPOINTS.assignRoleToUser, input), + createRole: (input) => post(HACKKIT_UI_ACTION_ENDPOINTS.createRole, input), + updateRole: (input) => post(HACKKIT_UI_ACTION_ENDPOINTS.updateRole, input), + deleteRole: (roleId) => post(HACKKIT_UI_ACTION_ENDPOINTS.deleteRole, { roleId }), + listSettings: () => post(HACKKIT_UI_ACTION_ENDPOINTS.listSettings, {}), + setSettings: (values: SetSettingsInput) => post(HACKKIT_UI_ACTION_ENDPOINTS.setSettings, values), + resetSetting: (key) => post(HACKKIT_UI_ACTION_ENDPOINTS.resetSetting, { key }), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc1f1785..ebee9770 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -525,9 +525,15 @@ importers: '@hackkit/ui': specifier: workspace:* version: link:../ui + better-call: + specifier: 1.3.5 + version: 1.3.5(zod@4.4.3) react: specifier: '>=18' version: 18.3.1 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@types/node': specifier: 20.14.11