diff --git a/.gitignore b/.gitignore
index 1af67420..84cb2b5d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,4 +55,7 @@ apps/web/.data/
HANDOFF.md
# build
-**/dist/
\ No newline at end of file
+**/dist/
+
+# Local research notes
+/docs/research/**/*.md
diff --git a/CONTEXT.md b/CONTEXT.md
index 923f834a..5b79e637 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -277,7 +277,7 @@ _Avoid_: **Organiser Approval** queue, **Hackathon Capacity**, automatic accepta
- Early **HackKit Web App** implementations may use guided “next step” links without hard redirects between onboarding steps; strict step enforcement may come later.
- **HackKit UI** may provide a **Competitor Onboarding Progress** component that displays step labels and completion state from props supplied by the **HackKit Web App** (no hard redirects).
- **HackKit Web Apps** may expose each **Competitor Onboarding** step at its own route under a shared prefix (for example `/onboarding/...`).
-- **HackKit UI** ships default forms for **Competitor Onboarding** steps, including **HackTag** claim, **User Data**, and **Hacker Registration**; **HackKit Web Apps** wire routes, server actions, and adapters.
+- **HackKit UI** ships default forms for **Competitor Onboarding** steps, including **HackTag** claim, **User Data**, and **Hacker Registration**; **HackKit Web Apps** wire routes, a typed browser path client, and adapters.
- **Hacker Registration** resume uploads use a **Blob Storage Adapter** in the **HackKit Web App**; **HackKit Core** stores only the **Stored File Reference** on the **Hacker** record when provided; omitting a resume leaves `resumeUrl` unset (no sentinel placeholder).
- A **Hacker** belongs to exactly one **User**.
- A **Hacker** may have zero or one **Group**.
diff --git a/apps/web/README.md b/apps/web/README.md
index f1a256a8..6d1ace8d 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -8,12 +8,12 @@ Use Node.js 22.17 or newer and pnpm 8.3.1. Run these commands from the repositor
1. Install dependencies with `pnpm install`.
2. Build workspace packages with `pnpm exec turbo run build --filter=web^...`.
-3. Sync plugin routes and actions with `pnpm --filter web sync`.
+3. Sync plugin routes with `pnpm --filter web sync`.
4. Apply committed migrations with `pnpm --filter web db:migrate`.
5. Insert configured roles with `pnpm --filter web db:seed`.
6. Start Next.js with `pnpm --filter web dev`.
-Development defaults use `http://localhost:3000`, SQLite at `apps/web/.data/web.db`, and local blob storage. OAuth, email, and Discord providers are optional locally. The runtime does not migrate or seed on startup. Plugin sync only updates routes, actions, and `hackkit.lock`.
+Development defaults use `http://localhost:3000`, SQLite at `apps/web/.data/web.db`, and local blob storage. OAuth, email, and Discord providers are optional locally. The runtime does not migrate or seed on startup. Plugin sync only updates routes and `hackkit.lock`.
After signup, bootstrap an owner explicitly by assigning `core.owner` to that user's `role_id` in `core_user`, selected by `id`. Better Auth's `user.id` is the canonical ID. The runtime does not infer ownership from email.
@@ -51,7 +51,7 @@ The database commands now use application-owned utility scripts, independently o
[lib/auth-options.ts](lib/auth-options.ts) supplies secrets, origins, and OAuth providers. Core owns Better Auth construction, native entities, and the pinned community adapter. Better Auth identity and HackKit profile fields share the canonical `core_user` row.
-[lib/runtime.ts](lib/runtime.ts) initializes shared connections and registers a function that creates request-scoped domain APIs. Next owns cookies, headers, guards, and redirects. Server actions return plain DTOs. Browser code uses `@hackkit/core/client`; it does not import the ORM.
+[lib/runtime.ts](lib/runtime.ts) initializes shared connections and registers a function that creates request-scoped domain APIs. Next owns cookies, headers, guards, and redirects. Browser mutations use the package-owned HTTP handler and return JSON DTOs. Browser code uses `@hackkit/core/client`; it does not import the ORM.
## Production configuration
@@ -83,4 +83,4 @@ The matrix writes test accounts and domain records; it never drops the target da
- [hackkit.config.ts](hackkit.config.ts): plugins, database, auth, settings, and seed roles.
- [db/migrations](db/migrations): reviewed app-owned migrations.
-UI mutations and `hackKitUIActions` ship from `@hackkit/next`. Plugin server actions in `app/hackkit-plugin-actions.ts` are generated by `hackkit plugin sync` and share the same request runtime.
+The UI provider receives a typed better-call path client created at module scope in `app/providers.tsx`. Plugin components bind their endpoint types using the provider's HTTP settings. `hackkit plugin sync` installs page reexports and updates `hackkit.lock`; it generates no client metadata. The single `/api/hackkit/[...path]` route forwards POST to the host handler. No app-level action wrappers or action maps are needed.
diff --git a/apps/web/app/_hackkit/server-actions.ts b/apps/web/app/_hackkit/server-actions.ts
deleted file mode 100644
index 556671fb..00000000
--- a/apps/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/web/app/_hackkit/ui-action-map.ts b/apps/web/app/_hackkit/ui-action-map.ts
deleted file mode 100644
index e01fd82c..00000000
--- a/apps/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/web/app/api/hackkit/[...path]/route.ts b/apps/web/app/api/hackkit/[...path]/route.ts
new file mode 100644
index 00000000..7584eab0
--- /dev/null
+++ b/apps/web/app/api/hackkit/[...path]/route.ts
@@ -0,0 +1,4 @@
+import { handleHackkitRequest } from "@/lib/runtime";
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const POST = handleHackkitRequest;
diff --git a/apps/web/app/hackkit-plugin-actions.ts b/apps/web/app/hackkit-plugin-actions.ts
deleted file mode 100644
index 6d8e8808..00000000
--- a/apps/web/app/hackkit-plugin-actions.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-"use server";
-
-// @hackkit-generated — do not edit
-
-import { getRuntime } from "@/lib/runtime";
-import { createTeamsActions } from "@hackkit/plugin-teams/actions";
-import { createDiscordActions } from "@hackkit/plugin-discord/actions";
-
-export async function createTeam(
- ...args: Parameters<
- Awaited>["createTeam"]
- >
-) {
- const actions = await createTeamsActions(await getRuntime());
- return actions.createTeam(...args);
-}
-
-export async function inviteToTeam(
- ...args: Parameters<
- Awaited>["inviteToTeam"]
- >
-) {
- const actions = await createTeamsActions(await getRuntime());
- return actions.inviteToTeam(...args);
-}
-
-export async function respondToInvite(
- ...args: Parameters<
- Awaited>["respondToInvite"]
- >
-) {
- const actions = await createTeamsActions(await getRuntime());
- return actions.respondToInvite(...args);
-}
-
-export async function leaveTeam(
- ...args: Parameters<
- Awaited>["leaveTeam"]
- >
-) {
- const actions = await createTeamsActions(await getRuntime());
- return actions.leaveTeam(...args);
-}
-
-export async function removeMember(
- ...args: Parameters<
- Awaited>["removeMember"]
- >
-) {
- const actions = await createTeamsActions(await getRuntime());
- return actions.removeMember(...args);
-}
-
-export async function confirmDiscordVerification(
- ...args: Parameters<
- Awaited<
- ReturnType
- >["confirmDiscordVerification"]
- >
-) {
- const actions = await createDiscordActions(await getRuntime());
- return actions.confirmDiscordVerification(...args);
-}
-
-export async function syncDiscordMemberRoles(
- ...args: Parameters<
- Awaited<
- ReturnType
- >["syncDiscordMemberRoles"]
- >
-) {
- const actions = await createDiscordActions(await getRuntime());
- return actions.syncDiscordMemberRoles(...args);
-}
diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx
index a810728a..35d6da07 100644
--- a/apps/web/app/providers.tsx
+++ b/apps/web/app/providers.tsx
@@ -1,21 +1,12 @@
+"use client";
import { HackKitUIProvider } from "@hackkit/ui";
+import { createHackkitClient } from "@hackkit/core/http/client";
import type * as React from "react";
-import { hackKitUIActions } from "./_hackkit/ui-action-map";
-/**
- * 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.
- *
- * Do not pass `DEFAULT_HACKKIT_UI_ROUTES` (or any map with route builder
- * functions) from this Server 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.
- */
+const hackkitClient = createHackkitClient();
+
export function Providers({ children }: { children: React.ReactNode }) {
return (
-
- {children}
-
+ {children}
);
}
diff --git a/apps/web/hackkit.lock b/apps/web/hackkit.lock
index b2981180..0960cf2e 100644
--- a/apps/web/hackkit.lock
+++ b/apps/web/hackkit.lock
@@ -14,13 +14,6 @@
"host": "app/teams/page.tsx",
"source": "@hackkit/plugin-teams/app/teams/page"
}
- ],
- "actions": [
- "createTeam",
- "inviteToTeam",
- "respondToInvite",
- "leaveTeam",
- "removeMember"
]
},
{
@@ -36,18 +29,13 @@
"host": "app/discord/verify/page.tsx",
"source": "@hackkit/plugin-discord/app/discord/verify/page"
}
- ],
- "actions": [
- "confirmDiscordVerification",
- "syncDiscordMemberRoles"
]
},
{
"id": "notificationsEmail",
"package": "@hackkit/plugin-notifications-email",
"version": "0.0.0",
- "routes": [],
- "actions": []
+ "routes": []
}
]
}
diff --git a/apps/web/lib/runtime.ts b/apps/web/lib/runtime.ts
index 7391a512..27d87c0d 100644
--- a/apps/web/lib/runtime.ts
+++ b/apps/web/lib/runtime.ts
@@ -31,3 +31,5 @@ export async function getPageGuards() {
export function getCoreRuntime() {
return host.core;
}
+
+export const handleHackkitRequest = host.handler;
diff --git a/apps/web/scripts/test-runtime.mjs b/apps/web/scripts/test-runtime.mjs
index 90566660..75f49f79 100644
--- a/apps/web/scripts/test-runtime.mjs
+++ b/apps/web/scripts/test-runtime.mjs
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
+import { createHackkitClient } from "@hackkit/core/http/client";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
@@ -123,8 +124,107 @@ try {
307,
);
assert.equal((await fetch(`${baseURL}/api/admin/export`)).status, 401);
+ const client = createHackkitClient({
+ baseURL: baseURL + "/api/hackkit",
+ });
+ const callOptions = accounts.map((account) => ({
+ headers: { cookie: account.cookie, origin: baseURL },
+ }));
+ const tags = ["http-first", "http-second"];
+ const claimed = await Promise.all(
+ accounts.map((_, index) =>
+ client("@post/users/claim-hack-tag", {
+ body: { hackTag: tags[index] },
+ ...callOptions[index],
+ }),
+ ),
+ );
+ assert.deepEqual(
+ claimed.map((result) => result.data?.id),
+ accounts.map((account) => account.id),
+ );
+ assert.equal(typeof claimed[0].data.createdAt, "string");
+ const unauthenticated = await client("@post/users/claim-hack-tag", {
+ body: { hackTag: "anon" },
+ headers: { origin: baseURL },
+ });
+ assert.equal(unauthenticated.error.code, "UNAUTHORIZED");
+ assert.equal(unauthenticated.error.status, 401);
+ const forbidden = await client("@post/roles/create", {
+ body: { name: "Forbidden", position: 0, permissions: [] },
+ ...callOptions[0],
+ });
+ assert.equal(forbidden.error.status, 403);
+ const spoof = await fetch(baseURL + "/api/hackkit/users/claim-hack-tag", {
+ method: "POST",
+ headers: {
+ ...callOptions[0].headers,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({ hackTag: "spoof", authId: accounts[1].id }),
+ });
+ assert.equal(spoof.status, 400);
+ for (let index = 0; index < accounts.length; index++) {
+ const data = await client("@post/user-data/complete", {
+ body: {
+ age: 20,
+ gender: "prefer_not_to_answer",
+ race: "prefer_not_to_answer",
+ ethnicity: "prefer_not_to_answer",
+ shirtSize: "m",
+ dietaryRestrictions: ["none"],
+ hasAcceptedMLHCodeOfConduct: true,
+ hasSharedDataWithMLH: true,
+ isEmailable: true,
+ },
+ ...callOptions[index],
+ });
+ assert.equal(data.error, null, JSON.stringify(data.error));
+ const registration = await client("@post/hackers/register", {
+ body: {
+ university: "Test University",
+ major: "CS",
+ levelOfStudy: "undergraduate",
+ hackathonsAttended: 0,
+ softwareExperience: "beginner",
+ },
+ ...callOptions[index],
+ });
+ assert.equal(
+ registration.error,
+ null,
+ JSON.stringify(registration.error),
+ );
+ assert.equal(registration.data.userId, accounts[index].id);
+ }
+ const team = await client("@post/teams/create", {
+ body: { name: "HTTP team", tag: "http-team" },
+ ...callOptions[0],
+ });
+ assert.equal(team.error, null, JSON.stringify(team.error));
+ assert.equal(team.data.ownerAuthId, accounts[0].id);
+ const invite = await client("@post/teams/invite", {
+ body: { teamId: team.data.id, hackTag: tags[1] },
+ ...callOptions[0],
+ });
+ assert.equal(invite.error, null, JSON.stringify(invite.error));
+ const accepted = await client("@post/teams/respond-to-invite", {
+ body: { inviteId: invite.data.id, accept: true },
+ ...callOptions[1],
+ });
+ assert.equal(accepted.error, null, JSON.stringify(accepted.error));
+ const left = await client("@post/teams/leave", callOptions[1]);
+ assert.deepEqual(left, { data: null, error: null });
+ const ownerCannotLeave = await client("@post/teams/leave", callOptions[0]);
+ assert.equal(ownerCannotLeave.error.status, 422);
+ const discord = await client("@post/discord/confirm-verification", {
+ body: { code: "missing-code" },
+ ...callOptions[0],
+ });
+ assert.equal(discord.error.status, 404);
+
console.log(
- "Passed: HTTP signup, isolated sessions, onboarding rendering, permission denials, and anonymous redirects.",
+ "Passed: HTTP signup, isolated sessions, onboarding rendering, Core and Teams path mutations, actor rejection, permissions, and Discord routing.",
);
} finally {
if (server && server.exitCode === null) {
diff --git a/docs/adr/0007-event-pass-qr-in-hackkit-ui.md b/docs/adr/0007-event-pass-qr-in-hackkit-ui.md
index 097eda17..0d1d2eb2 100644
--- a/docs/adr/0007-event-pass-qr-in-hackkit-ui.md
+++ b/docs/adr/0007-event-pass-qr-in-hackkit-ui.md
@@ -1,5 +1,7 @@
# Keep Event Pass QR in HackKit UI, not HackKit Core
+> Updated October 14, 2026: QR helpers now live in the browser-safe `@hackkit/core/event-pass` transport module. UI reexports them. Core HTTP endpoints validate raw QR before calling the unchanged domain methods. This removes the Next-to-UI dependency. The domain methods still accept `targetAuthId`, and the UI still owns scanner interactions. See the [current integration guide](../guides/next-integration.md).
+
Hackathon check-in and event scan APIs in HackKit Core accept only domain fields such as `targetAuthId`. Event Pass QR encoding, parsing, and TTL validation live in HackKit UI. Server mutations in `@hackkit/next` import those helpers and accept raw QR strings from client actions before calling Core.
## Context
diff --git a/docs/guides/next-integration.md b/docs/guides/next-integration.md
index 77ec85ed..2a2c8250 100644
--- a/docs/guides/next-integration.md
+++ b/docs/guides/next-integration.md
@@ -1,40 +1,44 @@
# Next.js integration
-Use `@hackkit/next` to connect HackKit Core, Better Auth, and HackKit UI. The [package README](../../packages/next/README.md) describes the API; the [reference app](../../apps/web/README.md) contains setup and database commands.
+HackKit uses package-owned better-call endpoints and its typed path client. `@hackkit/next` supplies Next request setup and page guards. See [its README](../../packages/next/README.md) for the interface and [the reference app](../../apps/web/README.md) for database commands.
+
+| Concern | Owner | App integration |
+| --------------------------------------- | ------------------- | ---------------------------------------------------------- |
+| Connections, entities, Better Auth | Core | Supply database, auth, plugins, and app entities in config |
+| Server render session and scope | Next runtime | Register `host.getRuntime` as a factory |
+| Auth HTTP and cookies | Better Auth / Next | Forward GET/POST to the Core auth handler |
+| Browser endpoints and request isolation | Core and plugins | Forward POST from one catch-all route to `host.handler` |
+| Typed browser calls | better-call client | Pass a client to the UI provider |
+| Plugin page routes | CLI sync | Install package-owned page reexports |
+| Page access rules | Next guards | Resolve guards inside server layouts |
+| Migrations and seed data | App release process | Run explicit database commands |
-## Ownership
+## Request lifecycle
-| Concern | Owner | App integration |
-| ---------------------------------- | ------------------- | -------------------------------------------------------------- |
-| Connections, entities, Better Auth | Core | Supply `database`, `auth`, plugins, and app entities in config |
-| Request session and EntityManager | Next runtime | Register `host.getRuntime` as a function |
-| Auth HTTP and cookies | Next | Route GET/POST to Core's auth handler |
-| Guards and mutations | Request runtime | Resolve `getRuntime()` in layouts and actions |
-| Core UI actions | `hackKitUIActions` | Pass to `HackKitUIProvider` |
-| Plugin routes/actions | CLI sync | Import generated modules |
-| Migrations and seed data | App release process | Explicit CLI commands |
+Each browser call checks its origin and body format, resolves its Better Auth session, and executes the endpoint in a fresh native MikroORM RequestContext. The authenticated runner supplies the actor. Strict endpoint body schemas reject actor overrides. Domain methods enforce permissions and return plain DTOs.
-## Request lifecycle
+The endpoint factory serializes its result before returning it. Its inferred return type therefore matches JSON: dates become strings and void becomes null. HTTP success bodies contain the result directly. Better-call returns it as `{ data, error: null }`. Server failures retain safe messages and stable codes. Better-call adds HTTP status and status text to its error result.
-The app creates one host with `createHackkitRuntimeFromConfig({ config })`. Its Core promise owns shared connections and auth. `setHackkitRuntime(host.getRuntime)` registers a factory. Each request resolves its session, forks an EntityManager, and binds domain and plugin APIs to the authenticated actor. Settings are read through that scope. No request state is retained in a module singleton.
+Session response cookies propagate through the handler, including expired-session cookie removal. Missing and expired sessions return 401. Components retain their draft and offer sign-in in another tab. Success navigation and refresh remain caller-owned. Mutations are not automatically retried.
-Auth signup creates the Better Auth identity and HackKit profile in one transaction. Session reads use the existing profile, so user edits survive later sign-ins. Domain methods return plain DTOs; authentication records and managed entities stay on the server.
+Server pages keep calling Core through `getRuntime()`. Jobs use `core.run(callback, actorAuthId)` or an explicit scope. These paths need no browser wrappers.
-For background jobs, use Core's `runtime.run(callback, actorAuthId)` or create an explicit scope. Close the Core ORM when the process ends. Browser components import `@hackkit/core/client` for schemas, constants, and types.
+## Browser calls and plugins
-## Page integration
+The app imports `createHackkitClient` from `@hackkit/core/http/client`, creates its client at module scope in `app/providers.tsx`, and passes it to `HackKitUIProvider`. A call uses a typed path and options, for example `client("@post/users/claim-hack-tag", { body: values })`. Schemas and domain constants remain available from `@hackkit/core/client`.
-| Area | Guard | Mutation examples |
-| ---------------- | ------------------------------------------ | ---------------------------------------------- |
-| Onboarding | `requireOnboardingAccess` | Claim tag, complete User Data, register Hacker |
-| Participant | `requireParticipantAccess` | Profile, RSVP, teams |
-| Pending approval | `requireApprovalPendingAccess` | Read approval status |
-| Admin | `requireNotBanned` and `requirePermission` | Administration, scans, settings |
+Each endpoint plugin defines a server endpoint factory and exposes it as `endpoints`. Its `/client` entry exports only the inferred endpoint type, such as `TeamsEndpoints`. Plugin components call `useHackkitClient()` to bind a native better-call client using the provider's HTTP settings. These are small memoized call functions. They make no discovery requests and import no endpoint implementations into the browser. An application that calls plugin paths directly can use `createHackkitClient()`, or intersect multiple plugin endpoint types.
-Use the runtime's guards and mutations. Keep the UI provider map in `@hackkit/next`. Plugin actions use their package's `/actions` entrypoint and the same registered runtime. Route maps passed from server components must contain serializable values; custom builders belong in a client navigation adapter.
+`hackkit plugin sync` installs page reexports and updates the lockfile. It generates no browser client, route metadata, or action wrappers. Package builds and watch mode use ordinary `tsup` without a metadata-generation step.
-## Verification
+Plugins with `enabled: false` retain their database entities, settings, and permissions. The handler skips their endpoint factories, and their pages use the enabled-plugin guard. Client types describe expected endpoints; server configuration determines which routes exist.
+
+The pinned better-call client checks paths and field value types. Required bodies and unknown body keys are enforced by the server schemas. Network and parsing exceptions use better-fetch's `catchAllError` result with `statusText: "Fetch Error"`; the UI shows a retry message. There is no custom network-error taxonomy or response-envelope decoder.
-Package tests cover action-map parity, redirects, session isolation, and profile preservation. The app matrix validates committed migrations and auth/domain behavior across supported drivers. Run the package tests, `pnpm --filter web typecheck`, and `pnpm --filter web build` after building the web dependency graph.
+The initial interface contains 25 Core, five Teams, and two Discord operations. Server-rendered reads remain direct Core calls. The current handler supports POST operations; GET/query inputs and dynamic path parameters are outside the exercised contract.
+
+Core endpoint definitions are grouped by domain in `packages/core/src/http/endpoints/`. Its `index.ts` composes the groups into one endpoint map. All groups use the shared authenticated runner, and scan operations share the QR schema and target resolution in the Event Pass group.
+
+## Verification
-See [ADR 0013](../adr/0013-mikroorm-and-core-auth.md) for registry, transaction, authentication, and migration decisions.
+Use the repository-pinned pnpm through `corepack pnpm`. Run package builds, typechecks, and tests, then the web production build and `corepack pnpm --filter web test:runtime`. HTTP checks use a fresh disposable SQLite database. `node tooling/check-client-bundle.mjs` bundles the provider entry and checks its inputs for recognized server implementations.
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 32f448f8..91094acb 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -35,8 +35,7 @@
"dotenv": "^16.6.1",
"jiti": "^2.4.2",
"@mikro-orm/core": "7.2.1",
- "@mikro-orm/migrations": "7.2.1",
- "prettier": "^3.3.3"
+ "@mikro-orm/migrations": "7.2.1"
},
"devDependencies": {
"@types/node": "22.18.6",
diff --git a/packages/cli/src/__tests__/plugin-actions-scope.test.ts b/packages/cli/src/__tests__/plugin-actions-scope.test.ts
deleted file mode 100644
index cf248232..00000000
--- a/packages/cli/src/__tests__/plugin-actions-scope.test.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import ts from "typescript";
-import { expect, it, vi } from "vitest";
-import { renderPluginActionsFile } from "../plugin-sync";
-
-it("resolves each generated action's actor when called, without capturing a module-level request", async () => {
- const source = await renderPluginActionsFile([
- {
- id: "example",
- packageName: "@example/plugin",
- actionFactory: "createExampleActions",
- actionNames: ["submit"],
- },
- ]);
- const getRuntime = vi
- .fn()
- .mockResolvedValueOnce({ actor: "first" })
- .mockResolvedValueOnce({ actor: "second" });
- const imports: Record = {
- "@/lib/runtime": { getRuntime },
- "@example/plugin/actions": {
- createExampleActions: (runtime: { actor: string }) => ({
- submit: async () => runtime.actor,
- }),
- },
- };
- const output = ts.transpileModule(source, {
- compilerOptions: { module: ts.ModuleKind.CommonJS },
- }).outputText;
- const actions: Record Promise> = {};
- new Function("require", "exports", output)(
- (name: string) => imports[name],
- actions,
- );
- expect(getRuntime).not.toHaveBeenCalled();
- expect(await actions.submit()).toBe("first");
- expect(await actions.submit()).toBe("second");
- expect(getRuntime).toHaveBeenCalledTimes(2);
-});
diff --git a/packages/cli/src/__tests__/plugin-schema.test.ts b/packages/cli/src/__tests__/plugin-schema.test.ts
index 33423db5..2c6a03ba 100644
--- a/packages/cli/src/__tests__/plugin-schema.test.ts
+++ b/packages/cli/src/__tests__/plugin-schema.test.ts
@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(),
syncPlugins: vi.fn().mockResolvedValue({
routesWritten: 0,
- actionsWritten: 0,
stubsRemoved: 0,
}),
}));
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index b4678858..c027b083 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -59,7 +59,7 @@ const plugins = program
plugins
.command("sync")
- .description("Sync plugin routes, actions, and lockfile")
+ .description("Sync plugin routes and lockfile")
.option("-c, --config ", "path to HackKit config")
.action(async (commandOptions: { config?: string }) => {
const globalOptions = program.opts<{ config: string }>();
diff --git a/packages/cli/src/lockfile.ts b/packages/cli/src/lockfile.ts
index 9d5485d2..06176e48 100644
--- a/packages/cli/src/lockfile.ts
+++ b/packages/cli/src/lockfile.ts
@@ -8,7 +8,6 @@ export type HackkitLockPlugin = {
package: string;
version: string;
routes: HackkitLockRoute[];
- actions: string[];
};
export type HackkitLockfile = {
diff --git a/packages/cli/src/plugin-commands.ts b/packages/cli/src/plugin-commands.ts
index d9e84e57..dc7f3417 100644
--- a/packages/cli/src/plugin-commands.ts
+++ b/packages/cli/src/plugin-commands.ts
@@ -175,6 +175,6 @@ export async function runPluginSyncAll(
});
logger.log(
"info",
- `HackKit plugin sync completed (${result.routesWritten} routes, ${result.actionsWritten} actions, ${result.stubsRemoved} removed).`,
+ `HackKit plugin sync completed (${result.routesWritten} routes, ${result.stubsRemoved} removed).`,
);
}
diff --git a/packages/cli/src/plugin-sync.ts b/packages/cli/src/plugin-sync.ts
index 919b7f64..3f87a3f5 100644
--- a/packages/cli/src/plugin-sync.ts
+++ b/packages/cli/src/plugin-sync.ts
@@ -1,4 +1,3 @@
-import { format } from "prettier";
import {
existsSync,
mkdirSync,
@@ -57,7 +56,6 @@ export type PluginSyncOptions = {
export type PluginSyncResult = {
lockfile: HackkitLockfile;
routesWritten: number;
- actionsWritten: number;
stubsRemoved: number;
};
@@ -77,55 +75,6 @@ function renderRouteStub(
return lines.join("\n");
}
-export async function renderPluginActionsFile(
- plugins: readonly {
- id: string;
- packageName: string;
- actionFactory?: string;
- actionNames?: readonly string[];
- }[],
-): Promise {
- const lines = [
- '"use server";',
- "",
- GENERATED_HEADER,
- "",
- 'import { getRuntime } from "@/lib/runtime";',
- ];
-
- for (const plugin of plugins) {
- if (!plugin.actionFactory || !plugin.actionNames?.length) continue;
- lines.push(
- `import { ${plugin.actionFactory} } from "${plugin.packageName}/actions";`,
- );
- }
-
- lines.push("");
-
- for (const plugin of plugins) {
- if (!plugin.actionFactory || !plugin.actionNames?.length) continue;
- for (const actionName of plugin.actionNames) {
- lines.push(
- `export async function ${actionName}(`,
- `\t...args: Parameters<`,
- `\t\tAwaited>["${actionName}"]`,
- `\t>`,
- `) {`,
- ` const actions = await ${plugin.actionFactory}(await getRuntime());`,
- ` return actions.${actionName}(...args);`,
- `}`,
- "",
- );
- }
- }
-
- return format(lines.join("\n"), {
- parser: "typescript",
- useTabs: true,
- tabWidth: 4,
- });
-}
-
function collectRouteClaims(
resolvedPlugins: ReturnType[],
protectedPaths: readonly string[],
@@ -183,7 +132,6 @@ export async function runPluginSync(
host,
source,
})),
- actions: [...(resolved.plugin.actionNames ?? [])],
})),
};
@@ -218,26 +166,11 @@ export async function runPluginSync(
}
}
- const actionsPath = join(projectRoot, "app/hackkit-plugin-actions.ts");
- const actionsContent = await renderPluginActionsFile(
- resolvedPlugins.map((resolved) => ({
- id: resolved.plugin.id,
- packageName: resolved.packageName,
- actionFactory: resolved.plugin.actionFactory,
- actionNames: resolved.plugin.actionNames,
- })),
- );
- writeGeneratedFile(actionsPath, actionsContent);
-
writeFileSync(lockfilePath, serializeLockfile(nextLockfile), "utf8");
return {
lockfile: nextLockfile,
routesWritten,
- actionsWritten: resolvedPlugins.reduce(
- (total, plugin) => total + (plugin.plugin.actionNames?.length ?? 0),
- 0,
- ),
stubsRemoved,
};
}
diff --git a/packages/config/src/__tests__/config.test.ts b/packages/config/src/__tests__/config.test.ts
index 98ba3e18..628afcb5 100644
--- a/packages/config/src/__tests__/config.test.ts
+++ b/packages/config/src/__tests__/config.test.ts
@@ -1,5 +1,5 @@
import { SqliteDriver } from "@mikro-orm/sqlite";
-import { describe, expect, it } from "vitest";
+import { describe, expect, expectTypeOf, it } from "vitest";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
@@ -44,3 +44,20 @@ describe("HackKit config", () => {
]);
});
});
+
+it("preserves plugin tuples through normalization and represents omitted plugins as empty", () => {
+ const config = resolveHackkitConfig(
+ defineHackkitConfig({
+ database: { driver: SqliteDriver, dbName: ":memory:" },
+ auth: {},
+ plugins: [{ id: "example", setup: () => ({ answer: 42 }) }],
+ }),
+ );
+ expectTypeOf(config.plugins[0].id).toEqualTypeOf<"example">();
+ expectTypeOf(config.plugins[0].setup().answer).toEqualTypeOf();
+ const empty = resolveHackkitConfig({
+ database: { driver: SqliteDriver, dbName: ":memory:" },
+ auth: {},
+ });
+ expectTypeOf(empty.plugins).toEqualTypeOf<[]>();
+});
diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts
index a1727883..82c2cb7b 100644
--- a/packages/config/src/index.ts
+++ b/packages/config/src/index.ts
@@ -47,8 +47,10 @@ export type HackerRegistrationOptions = {
heardFrom?: readonly RegistrationOption[];
};
-export type HackkitConfig = {
- plugins?: readonly HackKitPlugin[];
+export type HackkitConfig<
+ TPlugins extends readonly HackKitPlugin[] = readonly HackKitPlugin[],
+> = {
+ plugins?: TPlugins;
database: HackKitDatabaseOptions;
auth: HackKitAuthOptions;
entities?: InitializeHackkitOptions["entities"];
@@ -62,11 +64,10 @@ export type HackkitConfig = {
blob?: HackkitBlobConfig;
};
-export type NormalizedHackkitConfig = Omit<
- HackkitConfig,
- "plugins" | "seedRoles"
-> & {
- plugins: readonly HackKitPlugin[];
+export type NormalizedHackkitConfig<
+ TPlugins extends readonly HackKitPlugin[] = readonly HackKitPlugin[],
+> = Omit, "plugins" | "seedRoles"> & {
+ plugins: TPlugins;
seedRoles: readonly HackkitSeedRole[];
};
@@ -76,9 +77,9 @@ export function defineHackkitConfig(
return config;
}
-export function resolveHackkitConfig(
- config: HackkitConfig,
-): NormalizedHackkitConfig {
+export function resolveHackkitConfig<
+ const TPlugins extends readonly HackKitPlugin[] = [],
+>(config: HackkitConfig): NormalizedHackkitConfig {
if (!config?.database?.driver) {
throw new Error(
"HackKit config must include native MikroORM database options and a driver.",
@@ -88,7 +89,7 @@ export function resolveHackkitConfig(
throw new Error("HackKit config must include Better Auth options.");
return {
...config,
- plugins: config.plugins ?? [],
+ plugins: config.plugins ?? ([] as unknown as TPlugins),
seedRoles: config.seedRoles ?? [],
};
}
diff --git a/packages/core/package.json b/packages/core/package.json
index 3db7ef78..1ae955d3 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -17,6 +17,18 @@
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js"
+ },
+ "./http": {
+ "types": "./dist/http/index.d.ts",
+ "import": "./dist/http/index.js"
+ },
+ "./event-pass": {
+ "types": "./dist/http/event-pass.d.ts",
+ "import": "./dist/http/event-pass.js"
+ },
+ "./http/client": {
+ "types": "./dist/http/client.d.ts",
+ "import": "./dist/http/client.js"
}
},
"files": [
@@ -37,7 +49,9 @@
"@mikro-orm/postgresql": "7.2.1",
"@mikro-orm/sqlite": "7.2.1",
"better-auth": "1.7.6",
- "zod": "^3.25.67"
+ "zod": "^3.25.67",
+ "better-call": "1.4.0",
+ "@better-fetch/fetch": "1.3.2"
},
"devDependencies": {
"typescript": "5.9.3",
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 0342b89e..84b2211c 100644
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -9,3 +9,5 @@ export * from "./event-types";
export * from "./groups";
export type * from "./types";
export type { RsvpSummary } from "./functions/rsvp";
+
+export * from "./http/client";
diff --git a/packages/core/src/http/__tests__/handler.test.ts b/packages/core/src/http/__tests__/handler.test.ts
new file mode 100644
index 00000000..44dfd9e8
--- /dev/null
+++ b/packages/core/src/http/__tests__/handler.test.ts
@@ -0,0 +1,291 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { RequestContext } from "@mikro-orm/core";
+import { z } from "zod";
+import { createTestHackkit } from "../../testing";
+import { AuthSession } from "../../mikro/auth-entities";
+import { createHackkitClient } from "../client";
+import { createHackkitHandler } from "../handler";
+import { type HackKitPlugin } from "../../plugins";
+import { type RequestRunner } from "../mutation";
+import { createMutationFactory } from "../mutation";
+
+const origin = "http://localhost:3000";
+afterEach(() => vi.unstubAllGlobals());
+async function signup(
+ runtime: Awaited>["runtime"],
+ name: string,
+) {
+ const response = await runtime.auth.api.signUpEmail({
+ body: {
+ name,
+ email: `${name}@example.com`,
+ password: "a sufficiently long password",
+ },
+ asResponse: true,
+ });
+ expect(response.status).toBe(200);
+ // Session cookies are sent as Cookie on the following request, not Set-Cookie.
+ const cookie = response.headers
+ .getSetCookie()
+ .map((value) => value.split(";")[0])
+ .join("; ");
+ const session = await runtime.auth.api.getSession({
+ headers: new Headers({ cookie }),
+ });
+ expect(session).not.toBeNull();
+ return { cookie, id: session!.user.id };
+}
+
+it("publishes the UI operations with authenticated actors, isolated scopes, and the JSON contract", async () => {
+ const managers = new Set();
+ const plugin = {
+ id: "probe",
+ endpoints(run: RequestRunner) {
+ const post = createMutationFactory(run);
+ return {
+ empty: post(
+ "/probe/empty",
+ z.undefined(),
+ async () => undefined,
+ ),
+ failure: post("/probe/failure", z.undefined(), async () => {
+ throw new Error("private database details");
+ }),
+ scope: post(
+ "/probe/scope",
+ z.undefined(),
+ async ({ actorAuthId }) => {
+ const em = RequestContext.getEntityManager();
+ managers.add(em);
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ expect(RequestContext.getEntityManager()).toBe(em);
+ return { actorAuthId };
+ },
+ ),
+ };
+ },
+ } satisfies HackKitPlugin;
+ const disabledEndpoints = vi.fn((run: RequestRunner) => ({
+ action: createMutationFactory(run)(
+ "/disabled/action",
+ z.undefined(),
+ async () => "disabled",
+ ),
+ }));
+ const runtimeTest = await createTestHackkit({
+ userDataOptions: { shirtSize: [{ value: "custom", label: "Custom" }] },
+ plugins: [
+ plugin,
+ { id: "disabled", enabled: false, endpoints: disabledEndpoints },
+ ],
+ });
+ const { runtime } = runtimeTest;
+ const errors = vi.fn();
+ const handler = createHackkitHandler({
+ runtime,
+ trustedOrigins: [origin],
+ onUnexpectedError: errors,
+ });
+ expect(disabledEndpoints).not.toHaveBeenCalled();
+ const [alice, bob] = await Promise.all([
+ signup(runtime, "alice"),
+ signup(runtime, "bob"),
+ ]);
+ let requests = 0;
+ vi.stubGlobal(
+ "fetch",
+ async (input: string | URL | Request, init?: RequestInit) => {
+ requests++;
+ return handler(new Request(input, init));
+ },
+ );
+ const client = createHackkitClient({ baseURL: origin + "/api/hackkit" });
+ const headers = (cookie: string) => ({ cookie, origin });
+ const result = await Promise.all([
+ client("@post/users/claim-hack-tag", {
+ body: { hackTag: "alice" },
+ headers: headers(alice.cookie),
+ }),
+ client("@post/users/claim-hack-tag", {
+ body: { hackTag: "bob" },
+ headers: headers(bob.cookie),
+ }),
+ ]);
+ expect(result.map((value) => value.data?.id)).toEqual([alice.id, bob.id]);
+ expect(result[0]!.data?.createdAt).toEqual(expect.any(String));
+ expect(requests).toBe(2);
+ const send = (path: string, body?: unknown, cookie = alice.cookie) =>
+ handler(
+ new Request(origin + "/api/hackkit" + path, {
+ method: "POST",
+ headers: {
+ ...headers(cookie),
+ ...(body === undefined
+ ? {}
+ : { "content-type": "application/json" }),
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ }),
+ );
+ const anonymous = await send(
+ "/users/claim-hack-tag",
+ { hackTag: "anon" },
+ "",
+ );
+ expect(anonymous.status).toBe(401);
+ expect(await anonymous.json()).toMatchObject({ code: "UNAUTHORIZED" });
+ expect(
+ (
+ await send("/users/claim-hack-tag", {
+ hackTag: "spoof",
+ authId: bob.id,
+ })
+ ).status,
+ ).toBe(400);
+ expect(
+ (
+ await send("/hackers/register", {
+ userId: bob.id,
+ university: "Test U",
+ major: "CS",
+ levelOfStudy: "undergraduate",
+ hackathonsAttended: 0,
+ softwareExperience: "beginner",
+ })
+ ).status,
+ ).toBe(400);
+ expect(
+ (
+ await send("/roles/create", {
+ name: "Forbidden",
+ position: 0,
+ permissions: [],
+ })
+ ).status,
+ ).toBe(403);
+ expect(
+ (await send("/users/claim-hack-tag", { hackTag: "alice" }, bob.cookie))
+ .status,
+ ).toBe(409);
+ const completed = await send("/user-data/complete", {
+ age: 20,
+ gender: "prefer_not_to_answer",
+ race: "prefer_not_to_answer",
+ ethnicity: "prefer_not_to_answer",
+ shirtSize: "custom",
+ dietaryRestrictions: ["none"],
+ hasAcceptedMLHCodeOfConduct: true,
+ hasSharedDataWithMLH: true,
+ isEmailable: true,
+ });
+ expect(completed.status).toBe(200);
+ const empty = await send("/probe/empty");
+ expect(await empty.json()).toBeNull();
+ expect(empty.headers.get("cache-control")).toBe("no-store");
+ const emptyStream = new Request(origin + "/api/hackkit/probe/empty", {
+ method: "POST",
+ headers: headers(alice.cookie),
+ body: new Blob([]),
+ });
+ expect(emptyStream.body).not.toBeNull();
+ expect((await handler(emptyStream)).status).toBe(200);
+ const failure = await send("/probe/failure");
+ expect(failure.status).toBe(500);
+ expect(JSON.stringify(await failure.json())).not.toContain(
+ "private database",
+ );
+ expect(errors).toHaveBeenCalledOnce();
+ const scopes = await Promise.all([
+ send("/probe/scope"),
+ send("/probe/scope", undefined, bob.cookie),
+ ]);
+ expect(await scopes[0]!.json()).toEqual({ actorAuthId: alice.id });
+ expect(await scopes[1]!.json()).toEqual({ actorAuthId: bob.id });
+ expect(managers.size).toBe(2);
+ expect((await send("/disabled/action")).status).toBe(404);
+ const invalidOrigin = await handler(
+ new Request(origin + "/api/hackkit/probe/empty", {
+ method: "POST",
+ headers: {
+ cookie: alice.cookie,
+ origin: "https://example.invalid",
+ },
+ }),
+ );
+ expect(invalidOrigin.status).toBe(403);
+ const invalidMedia = await handler(
+ new Request(origin + "/api/hackkit/probe/empty", {
+ method: "POST",
+ headers: { ...headers(alice.cookie), "content-type": "text/plain" },
+ body: "{}",
+ }),
+ );
+ expect(invalidMedia.status).toBe(415);
+ await runtime.orm.em
+ .fork()
+ .nativeUpdate(
+ AuthSession,
+ { userId: alice.id },
+ { expiresAt: new Date(0) },
+ );
+ const expired = await send("/probe/empty");
+ expect(expired.status).toBe(401);
+ expect(expired.headers.getSetCookie().length).toBeGreaterThan(0);
+});
+
+it("returns native fetch failures without retrying mutations", async () => {
+ const fetch = vi.fn().mockRejectedValue(new TypeError("offline"));
+ vi.stubGlobal("fetch", fetch);
+ const result = await createHackkitClient({
+ baseURL: origin + "/api/hackkit",
+ })("@post/users/claim-hack-tag", { body: { hackTag: "retry" } });
+ expect(result).toMatchObject({
+ data: null,
+ error: { statusText: "Fetch Error", status: 500 },
+ });
+ expect(fetch).toHaveBeenCalledOnce();
+});
+
+// Compiled by the package typecheck. These assertions never send requests.
+async function typeContract() {
+ const client = createHackkitClient();
+ // @ts-expect-error Paths derive from endpoint definitions.
+ client("@post/users/missing");
+ client("@post/users/claim-hack-tag", {
+ // @ts-expect-error Body fields derive from the endpoint schema.
+ body: { hackTag: 123 },
+ });
+ // @ts-expect-error Core clients have no plugin endpoint types by default.
+ client("@post/teams/create", { body: { name: "team", tag: "team" } });
+ // @ts-expect-error Bodyless endpoints reject object bodies.
+ client("@post/rsvp/confirm", { body: {} });
+ const result = await client("@post/users/claim-hack-tag", {
+ body: { hackTag: "test" },
+ });
+ if (!result.error) {
+ const date: string = result.data.createdAt;
+ // @ts-expect-error JSON dates are strings.
+ const invalid: Date = result.data.createdAt;
+ }
+ const post = createMutationFactory(async () => {
+ throw new Error("Compile-only factory");
+ });
+ const endpoints = {
+ create: post(
+ "/probe/create",
+ z.object({ value: z.string() }).strict(),
+ async (_, input) => ({ value: input.value, createdAt: new Date() }),
+ ),
+ };
+ const withPlugin = createHackkitClient();
+ const pluginResult = await withPlugin("@post/probe/create", {
+ body: { value: "hello" },
+ });
+ if (!pluginResult.error) {
+ const date: string = pluginResult.data.createdAt;
+ }
+ withPlugin("@post/probe/create", {
+ // @ts-expect-error Plugin input types derive from the endpoint schema.
+ body: { value: 123 },
+ });
+}
diff --git a/packages/core/src/http/client.ts b/packages/core/src/http/client.ts
new file mode 100644
index 00000000..7e84c5fe
--- /dev/null
+++ b/packages/core/src/http/client.ts
@@ -0,0 +1,29 @@
+import { createClient, type ClientOptions } from "better-call/client";
+import type { Endpoint } from "better-call";
+import type { createCoreEndpoints } from "./endpoints";
+
+export type CoreEndpoints = ReturnType;
+export type HttpEndpoints = Record;
+export type HackkitClientOptions = Pick<
+ ClientOptions,
+ "baseURL" | "headers" | "customFetchImpl"
+>;
+
+/** Native better-call path inference. Endpoint imports are erased from the browser bundle. */
+export function createHackkitClient(
+ options: HackkitClientOptions = {},
+) {
+ const httpOptions = {
+ baseURL: "/api/hackkit",
+ credentials: "same-origin" as const,
+ retry: 0,
+ catchAllError: true,
+ ...options,
+ };
+ return Object.assign(
+ createClient(httpOptions),
+ { options },
+ );
+}
+export type HackkitClient = ReturnType>;
+export type { ApiResult, ApiError, ApiErrorCode, Wire } from "./http-contract";
diff --git a/packages/core/src/http/endpoints/event-pass.ts b/packages/core/src/http/endpoints/event-pass.ts
new file mode 100644
index 00000000..f34ef88e
--- /dev/null
+++ b/packages/core/src/http/endpoints/event-pass.ts
@@ -0,0 +1,50 @@
+import { z } from "zod";
+import type { HackKit } from "../../hackkit";
+import { HackKitError } from "../../errors";
+import { CorePermission } from "../../permissions";
+import { CoreSetting } from "../../settings";
+import { resolveEventPassTargetAuthId } from "../event-pass";
+import { createMutationFactory, type RequestRunner } from "../mutation";
+
+export const eventPassQrBody = z.object({ rawQr: z.string().min(1) });
+
+/** Resolve QR targets with the configured lifetime for every scan endpoint. */
+export async function resolveEventPassTarget(hackkit: HackKit, rawQr: string) {
+ const ttl = await hackkit.settings.getValue(CoreSetting.EventPassQrTtlMs);
+ return resolveEventPassTargetAuthId(rawQr, new Date(), Number(ttl));
+}
+
+export function createEventPassEndpoints(run: RequestRunner) {
+ const post = createMutationFactory(run);
+ return {
+ previewQr: post(
+ "/event-pass/preview",
+ eventPassQrBody
+ .extend({ eventId: z.string().min(1).optional() })
+ .strict(),
+ async ({ hackkit, actorAuthId }, input) => {
+ await hackkit.accessControl.requirePermission(
+ actorAuthId,
+ input.eventId
+ ? CorePermission.EventsScan
+ : CorePermission.UsersCheckIn,
+ );
+ const targetAuthId = await resolveEventPassTarget(
+ hackkit,
+ input.rawQr,
+ );
+ const user = await hackkit.users.getUser(targetAuthId);
+ if (!user)
+ throw new HackKitError("NOT_FOUND", "User not found.");
+ const priorScans = input.eventId
+ ? await hackkit.events.listEventScans({
+ actorAuthId,
+ eventId: input.eventId,
+ targetAuthId,
+ })
+ : [];
+ return { user, priorScans };
+ },
+ ),
+ };
+}
diff --git a/packages/core/src/http/endpoints/events.ts b/packages/core/src/http/endpoints/events.ts
new file mode 100644
index 00000000..d8b04208
--- /dev/null
+++ b/packages/core/src/http/endpoints/events.ts
@@ -0,0 +1,65 @@
+import { z } from "zod";
+import { deleteEventSchema } from "../../schemas";
+import { eventPassQrBody, resolveEventPassTarget } from "./event-pass";
+import { createMutationFactory, type RequestRunner } from "../mutation";
+
+const eventBody = z.object({
+ title: z.string().min(1).max(255),
+ description: z.string().min(1),
+ startTime: z.string().datetime({ offset: true }),
+ endTime: z.string().datetime({ offset: true }),
+ location: z.string().min(1).max(255),
+ type: z.string().min(1),
+ host: z
+ .string()
+ .max(255)
+ .transform((value) => value.trim() || undefined),
+ hidden: z.boolean(),
+});
+
+export function createEventEndpoints(run: RequestRunner) {
+ const post = createMutationFactory(run);
+ return {
+ createEvent: post(
+ "/events/create",
+ eventBody.strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.events.createEvent({
+ ...input,
+ startTime: new Date(input.startTime),
+ endTime: new Date(input.endTime),
+ actorAuthId,
+ }),
+ ),
+ updateEvent: post(
+ "/events/update",
+ eventBody.extend({ eventId: z.string().min(1) }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.events.updateEvent({
+ ...input,
+ startTime: new Date(input.startTime),
+ endTime: new Date(input.endTime),
+ actorAuthId,
+ }),
+ ),
+ deleteEvent: post(
+ "/events/delete",
+ deleteEventSchema.omit({ actorAuthId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.events.deleteEvent({ ...input, actorAuthId }),
+ ),
+ recordScan: post(
+ "/events/record-scan",
+ eventPassQrBody.extend({ eventId: z.string().min(1) }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.events.recordEventScan({
+ actorAuthId,
+ eventId: input.eventId,
+ targetAuthId: await resolveEventPassTarget(
+ hackkit,
+ input.rawQr,
+ ),
+ }),
+ ),
+ };
+}
diff --git a/packages/core/src/http/endpoints/index.ts b/packages/core/src/http/endpoints/index.ts
new file mode 100644
index 00000000..26a3e1fb
--- /dev/null
+++ b/packages/core/src/http/endpoints/index.ts
@@ -0,0 +1,25 @@
+import { resolveUserDataOptions } from "../../user-data-options";
+import type { RequestRunner } from "../mutation";
+import { createUserEndpoints } from "./users";
+import { createRegistrationEndpoints } from "./registration";
+import { createEventEndpoints } from "./events";
+import { createRsvpEndpoints } from "./rsvp";
+import { createRoleEndpoints } from "./roles";
+import { createSettingEndpoints } from "./settings";
+import { createEventPassEndpoints } from "./event-pass";
+
+/** Compose the package-owned browser operations. Authentication stays in the shared runner. */
+export function createCoreEndpoints(
+ run: RequestRunner,
+ userDataOptions = resolveUserDataOptions(),
+) {
+ return {
+ ...createUserEndpoints(run),
+ ...createRegistrationEndpoints(run, userDataOptions),
+ ...createEventEndpoints(run),
+ ...createRsvpEndpoints(run),
+ ...createRoleEndpoints(run),
+ ...createSettingEndpoints(run),
+ ...createEventPassEndpoints(run),
+ };
+}
diff --git a/packages/core/src/http/endpoints/registration.ts b/packages/core/src/http/endpoints/registration.ts
new file mode 100644
index 00000000..706b321a
--- /dev/null
+++ b/packages/core/src/http/endpoints/registration.ts
@@ -0,0 +1,35 @@
+import {
+ createCompleteUserDataSchema,
+ resolveUserDataOptions,
+} from "../../user-data-options";
+import { registerHackerSchema } from "../../schemas";
+import { createMutationFactory, type RequestRunner } from "../mutation";
+
+export function createRegistrationEndpoints(
+ run: RequestRunner,
+ userDataOptions = resolveUserDataOptions(),
+) {
+ const post = createMutationFactory(run);
+ return {
+ completeUserData: post(
+ "/user-data/complete",
+ createCompleteUserDataSchema(userDataOptions)
+ .omit({ authId: true })
+ .strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.userData.completeUserData({
+ ...input,
+ authId: actorAuthId,
+ }),
+ ),
+ registerHacker: post(
+ "/hackers/register",
+ registerHackerSchema.omit({ userId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.hackers.registerHacker({
+ ...input,
+ userId: actorAuthId,
+ }),
+ ),
+ };
+}
diff --git a/packages/core/src/http/endpoints/roles.ts b/packages/core/src/http/endpoints/roles.ts
new file mode 100644
index 00000000..a170adab
--- /dev/null
+++ b/packages/core/src/http/endpoints/roles.ts
@@ -0,0 +1,37 @@
+import {
+ assignRoleSchema,
+ createRoleSchema,
+ updateRoleSchema,
+ deleteRoleSchema,
+} from "../../schemas";
+import { createMutationFactory, type RequestRunner } from "../mutation";
+
+export function createRoleEndpoints(run: RequestRunner) {
+ const post = createMutationFactory(run);
+ return {
+ assignRole: post(
+ "/roles/assign",
+ assignRoleSchema.omit({ actorAuthId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.roles.assignRoleToUser({ ...input, actorAuthId }),
+ ),
+ createRole: post(
+ "/roles/create",
+ createRoleSchema.omit({ actorAuthId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.roles.createRole({ ...input, actorAuthId }),
+ ),
+ updateRole: post(
+ "/roles/update",
+ updateRoleSchema.omit({ actorAuthId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.roles.updateRole({ ...input, actorAuthId }),
+ ),
+ deleteRole: post(
+ "/roles/delete",
+ deleteRoleSchema.omit({ actorAuthId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.roles.deleteRole({ ...input, actorAuthId }),
+ ),
+ };
+}
diff --git a/packages/core/src/http/endpoints/rsvp.ts b/packages/core/src/http/endpoints/rsvp.ts
new file mode 100644
index 00000000..76e75b67
--- /dev/null
+++ b/packages/core/src/http/endpoints/rsvp.ts
@@ -0,0 +1,37 @@
+import { z } from "zod";
+import {
+ adminCancelRsvpSchema,
+ adminSetRsvpStatusSchema,
+ adminPromoteRsvpSchema,
+} from "../../schemas";
+import { createMutationFactory, type RequestRunner } from "../mutation";
+
+export function createRsvpEndpoints(run: RequestRunner) {
+ const post = createMutationFactory(run);
+ return {
+ confirmRsvp: post(
+ "/rsvp/confirm",
+ z.undefined(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.rsvp.confirm({ authId: actorAuthId }),
+ ),
+ cancelRsvp: post(
+ "/rsvp/cancel",
+ adminCancelRsvpSchema.omit({ actorAuthId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.rsvp.cancel({ ...input, actorAuthId }),
+ ),
+ setRsvpStatus: post(
+ "/rsvp/set-status",
+ adminSetRsvpStatusSchema.omit({ actorAuthId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.rsvp.setStatus({ ...input, actorAuthId }),
+ ),
+ promoteRsvp: post(
+ "/rsvp/promote",
+ adminPromoteRsvpSchema.omit({ actorAuthId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.rsvp.promote({ ...input, actorAuthId }),
+ ),
+ };
+}
diff --git a/packages/core/src/http/endpoints/settings.ts b/packages/core/src/http/endpoints/settings.ts
new file mode 100644
index 00000000..bfaf194e
--- /dev/null
+++ b/packages/core/src/http/endpoints/settings.ts
@@ -0,0 +1,42 @@
+import { z } from "zod";
+import type { SettingKey } from "../../settings";
+import { createMutationFactory, type RequestRunner } from "../mutation";
+
+const settingKey = z
+ .string()
+ .refine((value): value is SettingKey => /^[^.]+\..+$/.test(value));
+
+export function createSettingEndpoints(run: RequestRunner) {
+ const post = createMutationFactory(run);
+ return {
+ listSettings: post(
+ "/settings/list",
+ z.undefined(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.settings.list({ actorAuthId }),
+ ),
+ setSettings: post(
+ "/settings/set",
+ z
+ .object({
+ values: z.array(
+ z
+ .object({
+ key: settingKey,
+ value: z.union([z.boolean(), z.number()]),
+ })
+ .strict(),
+ ),
+ })
+ .strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.settings.setMany({ actorAuthId, values: input.values }),
+ ),
+ resetSetting: post(
+ "/settings/reset",
+ z.object({ key: settingKey }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.settings.reset({ actorAuthId, key: input.key }),
+ ),
+ };
+}
diff --git a/packages/core/src/http/endpoints/users.ts b/packages/core/src/http/endpoints/users.ts
new file mode 100644
index 00000000..43ee88eb
--- /dev/null
+++ b/packages/core/src/http/endpoints/users.ts
@@ -0,0 +1,64 @@
+import {
+ claimHackTagSchema,
+ updateUserProfileSchema,
+ clearCheckInUserSchema,
+ approveUserSchema,
+ banUserSchema,
+ unbanUserSchema,
+} from "../../schemas";
+import { eventPassQrBody, resolveEventPassTarget } from "./event-pass";
+import { createMutationFactory, type RequestRunner } from "../mutation";
+
+export function createUserEndpoints(run: RequestRunner) {
+ const post = createMutationFactory(run);
+ return {
+ claimHackTag: post(
+ "/users/claim-hack-tag",
+ claimHackTagSchema.omit({ authId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.users.claimHackTag({ ...input, authId: actorAuthId }),
+ ),
+ updateProfile: post(
+ "/users/update-profile",
+ updateUserProfileSchema.omit({ authId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.users.updateProfile({ ...input, authId: actorAuthId }),
+ ),
+ checkIn: post(
+ "/users/check-in",
+ eventPassQrBody.strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.users.checkIn({
+ actorAuthId,
+ targetAuthId: await resolveEventPassTarget(
+ hackkit,
+ input.rawQr,
+ ),
+ }),
+ ),
+ clearCheckIn: post(
+ "/users/clear-check-in",
+ clearCheckInUserSchema.omit({ actorAuthId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.users.clearCheckIn({ ...input, actorAuthId }),
+ ),
+ approveUser: post(
+ "/users/approve",
+ approveUserSchema.omit({ actorAuthId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.users.approveUser({ ...input, actorAuthId }),
+ ),
+ banUser: post(
+ "/users/ban",
+ banUserSchema.omit({ actorAuthId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.users.banUser({ ...input, actorAuthId }),
+ ),
+ unbanUser: post(
+ "/users/unban",
+ unbanUserSchema.omit({ actorAuthId: true }).strict(),
+ async ({ hackkit, actorAuthId }, input) =>
+ hackkit.users.unbanUser({ ...input, actorAuthId }),
+ ),
+ };
+}
diff --git a/packages/core/src/http/event-pass.ts b/packages/core/src/http/event-pass.ts
new file mode 100644
index 00000000..64cd73ec
--- /dev/null
+++ b/packages/core/src/http/event-pass.ts
@@ -0,0 +1,91 @@
+import { HackKitError } from "../errors";
+import type { AuthId } from "../types";
+
+export const DEFAULT_EVENT_PASS_QR_TTL_MS = 5 * 60 * 1000;
+
+export type EventPassQrPayload = {
+ authId: AuthId;
+ qrIssuedAt: number;
+};
+
+export function createEventPassQrPayload(
+ authId: AuthId,
+ issuedAt: Date,
+): string {
+ const payload: EventPassQrPayload = {
+ authId,
+ qrIssuedAt: issuedAt.getTime(),
+ };
+ return JSON.stringify(payload);
+}
+
+export function parseEventPassQrPayload(raw: string): {
+ authId: AuthId;
+ qrIssuedAt: Date;
+} {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ throw new HackKitError(
+ "VALIDATION_ERROR",
+ "Invalid Event Pass QR code.",
+ );
+ }
+
+ if (
+ typeof parsed !== "object" ||
+ parsed === null ||
+ !("authId" in parsed) ||
+ !("qrIssuedAt" in parsed) ||
+ typeof parsed.authId !== "string" ||
+ typeof parsed.qrIssuedAt !== "number"
+ ) {
+ throw new HackKitError(
+ "VALIDATION_ERROR",
+ "Invalid Event Pass QR code.",
+ );
+ }
+
+ return {
+ authId: parsed.authId,
+ qrIssuedAt: new Date(parsed.qrIssuedAt),
+ };
+}
+
+export function validateEventPassQrIssuedAt(
+ qrIssuedAt: Date,
+ now: Date,
+ ttlMs: number,
+): void {
+ if (Number.isNaN(qrIssuedAt.getTime())) {
+ throw new HackKitError(
+ "VALIDATION_ERROR",
+ "Invalid Event Pass QR code.",
+ );
+ }
+
+ if (now.getTime() - qrIssuedAt.getTime() > ttlMs) {
+ throw new HackKitError(
+ "VALIDATION_ERROR",
+ "Event Pass QR code has expired. Ask the participant to refresh it.",
+ );
+ }
+
+ if (qrIssuedAt.getTime() > now.getTime() + 60_000) {
+ throw new HackKitError(
+ "VALIDATION_ERROR",
+ "Invalid Event Pass QR code.",
+ );
+ }
+}
+
+export function resolveEventPassTargetAuthId(
+ rawQr: string,
+ now: Date,
+ ttlMs: number,
+): AuthId {
+ const { authId, qrIssuedAt } = parseEventPassQrPayload(rawQr);
+ validateEventPassQrIssuedAt(qrIssuedAt, now, ttlMs);
+ return authId;
+}
diff --git a/packages/core/src/http/handler.ts b/packages/core/src/http/handler.ts
new file mode 100644
index 00000000..8fdc021e
--- /dev/null
+++ b/packages/core/src/http/handler.ts
@@ -0,0 +1,83 @@
+import { createRouter, APIError, type Endpoint } from "better-call";
+import { createLogger, type HackKitLoggerOptions } from "../adapters/logger";
+import { HackKitError } from "../errors";
+import type { HackKitRuntime } from "../runtime";
+import { createCoreEndpoints } from "./endpoints";
+import { protectHttpHandler, httpError } from "./http-server";
+import type { RequestRunner } from "./mutation";
+
+/** Mount Core and enabled plugins once. Only connections and endpoint definitions are shared. */
+export function createHackkitHandler(options: {
+ runtime: HackKitRuntime;
+ trustedOrigins: readonly string[];
+ basePath?: string;
+ logger?: HackKitLoggerOptions;
+ onUnexpectedError?: (error: unknown) => void;
+}) {
+ const logger = createLogger(options.logger);
+ const onUnexpectedError =
+ options.onUnexpectedError ??
+ ((error) => logger.log("error", "HackKit HTTP request failed.", error));
+ const sessionHeaders = new WeakMap();
+ const run: RequestRunner = async (request, operation) => {
+ const session = await options.runtime.auth.api.getSession({
+ headers: request.headers,
+ query: { disableCookieCache: true },
+ returnHeaders: true,
+ });
+ sessionHeaders.set(request, session.headers);
+ if (!session.response)
+ throw new HackKitError("UNAUTHORIZED", "Sign in to continue.");
+ const actorAuthId = session.response.user.id;
+ return options.runtime.run(
+ ({ hackkit }) => operation({ hackkit, actorAuthId }),
+ actorAuthId,
+ );
+ };
+ const endpoints: Record = {
+ ...createCoreEndpoints(
+ run,
+ options.runtime.createScope().hackkit.userData.options,
+ ),
+ };
+ for (const plugin of Object.values(options.runtime.registry.plugins)) {
+ if (plugin.enabled === false) continue;
+ for (const [name, endpoint] of Object.entries(
+ plugin.endpoints?.(run) ?? {},
+ )) {
+ if (!endpoint.path.startsWith(`/${plugin.id}/`))
+ throw new Error(`Plugin endpoints must use /${plugin.id}/.`);
+ endpoints[`${plugin.id}.${name}`] = endpoint;
+ }
+ }
+ const paths = new Set();
+ for (const endpoint of Object.values(endpoints)) {
+ if (endpoint.options.method !== "POST")
+ throw new Error("HackKit browser endpoints must use POST.");
+ if (paths.has(endpoint.path))
+ throw new Error(`Duplicate HTTP route: POST ${endpoint.path}`);
+ paths.add(endpoint.path);
+ }
+ const router = createRouter(endpoints, {
+ basePath: options.basePath ?? "/api/hackkit",
+ onError(error) {
+ if (
+ !(error instanceof HackKitError) &&
+ !(error instanceof APIError && error.statusCode < 500)
+ )
+ onUnexpectedError(error);
+ return httpError(error);
+ },
+ });
+ return protectHttpHandler(
+ async (request: Request) => {
+ const response = await router.handler(request);
+ const headers = sessionHeaders.get(request);
+ sessionHeaders.delete(request);
+ for (const cookie of headers?.getSetCookie() ?? [])
+ response.headers.append("set-cookie", cookie);
+ return response;
+ },
+ { ...options, onUnexpectedError },
+ );
+}
diff --git a/packages/core/src/http/http-contract.ts b/packages/core/src/http/http-contract.ts
new file mode 100644
index 00000000..ce2d5915
--- /dev/null
+++ b/packages/core/src/http/http-contract.ts
@@ -0,0 +1,34 @@
+import type { BetterFetchResponse } from "@better-fetch/fetch";
+
+export type ApiResult = BetterFetchResponse;
+export type ApiError = NonNullable["error"]>;
+export type ApiErrorCode =
+ | "VALIDATION_ERROR"
+ | "NOT_FOUND"
+ | "CONFLICT"
+ | "FORBIDDEN"
+ | "UNAUTHORIZED"
+ | "INVALID_OPERATION"
+ | "INTERNAL_SERVER_ERROR"
+ | "UNSUPPORTED_MEDIA_TYPE"
+ | "HTTP_ERROR";
+
+type JsonDto = T extends Date
+ ? string
+ : T extends string | number | boolean | null | undefined
+ ? T
+ : T extends readonly (infer Item)[]
+ ? Wire- []
+ : T extends object
+ ? { [Key in keyof T]: JsonDto }
+ : T;
+export type Wire = T extends undefined | void ? null : JsonDto;
+
+/** Domain operations return plain DTOs. Serialize before returning so inferred endpoint types match JSON. */
+export function toJsonDto(value: T): Wire {
+ const json: unknown = JSON.parse(JSON.stringify(value ?? null));
+ return json as Wire;
+}
+export function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
diff --git a/packages/core/src/http/http-server.ts b/packages/core/src/http/http-server.ts
new file mode 100644
index 00000000..6c02d561
--- /dev/null
+++ b/packages/core/src/http/http-server.ts
@@ -0,0 +1,175 @@
+import { APIError } from "better-call";
+import { HackKitError, type HackKitErrorCode } from "../errors";
+import { isRecord, type ApiErrorCode } from "./http-contract";
+
+const domainStatus = {
+ VALIDATION_ERROR: 400,
+ UNAUTHORIZED: 401,
+ FORBIDDEN: 403,
+ NOT_FOUND: 404,
+ CONFLICT: 409,
+ INVALID_OPERATION: 422,
+} satisfies Record;
+
+function codeForStatus(status: number): ApiErrorCode {
+ switch (status) {
+ case 400:
+ return "VALIDATION_ERROR";
+ case 401:
+ return "UNAUTHORIZED";
+ case 403:
+ return "FORBIDDEN";
+ case 404:
+ return "NOT_FOUND";
+ case 409:
+ return "CONFLICT";
+ case 415:
+ return "UNSUPPORTED_MEDIA_TYPE";
+ case 422:
+ return "INVALID_OPERATION";
+ default:
+ return status >= 500 ? "INTERNAL_SERVER_ERROR" : "HTTP_ERROR";
+ }
+}
+
+function responseHeaders(source?: HeadersInit): Headers {
+ const headers = new Headers(source);
+ headers.delete("content-length");
+ headers.delete("content-encoding");
+ headers.set("content-type", "application/json");
+ headers.set("cache-control", "no-store");
+ return headers;
+}
+
+function errorResponse(
+ status: number,
+ code: ApiErrorCode,
+ message: string,
+ headers?: HeadersInit,
+) {
+ return Response.json(
+ { code, message },
+ { status, headers: responseHeaders(headers) },
+ );
+}
+
+/** Domain failures stay useful; unexpected exception details stay on the server. */
+export function httpError(error: unknown): Response {
+ if (error instanceof HackKitError)
+ return errorResponse(
+ domainStatus[error.code],
+ error.code,
+ error.message,
+ );
+ if (error instanceof APIError && error.statusCode < 500) {
+ return errorResponse(
+ error.statusCode,
+ codeForStatus(error.statusCode),
+ error.body?.message ?? "The request failed.",
+ );
+ }
+ return errorResponse(
+ 500,
+ "INTERNAL_SERVER_ERROR",
+ "Something went wrong. Please try again.",
+ );
+}
+
+/** One response policy for the whole mounted handler, including router-level 404s. */
+async function finalizeResponse(response: Response): Promise {
+ const hasBody = response.body !== null;
+ const text = await response.text();
+ if (response.ok) {
+ const data: unknown = !hasBody
+ ? null
+ : !text.length ||
+ response.headers.get("content-type")?.startsWith("text/plain")
+ ? text
+ : JSON.parse(text);
+ return Response.json(data, {
+ status: response.status === 204 ? 200 : response.status,
+ headers: responseHeaders(response.headers),
+ });
+ }
+ let body: unknown;
+ try {
+ body = text ? JSON.parse(text) : null;
+ } catch {
+ body = null;
+ }
+ const message =
+ response.status >= 500
+ ? "Something went wrong. Please try again."
+ : isRecord(body) && typeof body.message === "string"
+ ? body.message
+ : response.status === 404
+ ? "Endpoint not found."
+ : "The request failed.";
+ return errorResponse(
+ response.status,
+ codeForStatus(response.status),
+ message,
+ response.headers,
+ );
+}
+
+/** Browser mutation endpoints require an explicit configured origin and JSON bodies. */
+export function protectHttpHandler(
+ handler: (request: Request) => Promise,
+ options: {
+ trustedOrigins: readonly string[];
+ onUnexpectedError?: (error: unknown) => void;
+ },
+) {
+ return async (request: Request): Promise => {
+ try {
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ const origin = request.headers.get("origin");
+ if (
+ !origin ||
+ !options.trustedOrigins.includes(origin) ||
+ request.headers.get("sec-fetch-site") === "cross-site"
+ ) {
+ return errorResponse(
+ 403,
+ "FORBIDDEN",
+ "Request origin is not allowed.",
+ );
+ }
+ const mediaType = request.headers
+ .get("content-type")
+ ?.split(";")[0]
+ .trim()
+ .toLowerCase();
+ // Next may expose an empty stream for a bodyless POST. Check its bytes
+ // before requiring a content type; nonempty bodies still require JSON.
+ const hasBody =
+ request.body !== null &&
+ (mediaType !== undefined ||
+ (await request.clone().arrayBuffer()).byteLength > 0);
+ if (request.body !== null && !hasBody) {
+ request = new Request(request.url, {
+ method: request.method,
+ headers: request.headers,
+ signal: request.signal,
+ });
+ }
+ if (hasBody && mediaType !== "application/json") {
+ return errorResponse(
+ 415,
+ "UNSUPPORTED_MEDIA_TYPE",
+ "Mutation bodies must use application/json.",
+ );
+ }
+ }
+ return await finalizeResponse(await handler(request));
+ } catch (error) {
+ if (
+ !(error instanceof APIError) &&
+ !(error instanceof HackKitError)
+ )
+ options.onUnexpectedError?.(error);
+ return httpError(error);
+ }
+ };
+}
diff --git a/packages/core/src/http/index.ts b/packages/core/src/http/index.ts
new file mode 100644
index 00000000..0c3226ed
--- /dev/null
+++ b/packages/core/src/http/index.ts
@@ -0,0 +1,7 @@
+export { createHackkitHandler } from "./handler";
+export { createMutationFactory, createPluginMutationFactory } from "./mutation";
+export type {
+ RequestRunner,
+ AuthenticatedScope,
+ EndpointFactory,
+} from "./mutation";
diff --git a/packages/core/src/http/mutation.ts b/packages/core/src/http/mutation.ts
new file mode 100644
index 00000000..c31191b3
--- /dev/null
+++ b/packages/core/src/http/mutation.ts
@@ -0,0 +1,74 @@
+import { createEndpoint, type Endpoint } from "better-call";
+import { z } from "zod";
+import { HackKitError } from "../errors";
+import { toJsonDto } from "./http-contract";
+import type { HackKit } from "../hackkit";
+
+export type AuthenticatedScope = { hackkit: HackKit; actorAuthId: string };
+export type RequestRunner = (
+ request: Request,
+ operation: (scope: AuthenticatedScope) => Promise,
+) => Promise;
+export type EndpointFactory = (run: RequestRunner) => Record;
+
+/** Schemas own browser inputs. The request runner supplies the authenticated actor. */
+export function createMutationFactory(run: RequestRunner) {
+ return function mutation<
+ const TPath extends string,
+ TSchema extends z.ZodType,
+ TOutput,
+ >(
+ path: TPath,
+ body: TSchema,
+ operation: (
+ scope: AuthenticatedScope,
+ input: z.output,
+ ) => Promise,
+ ) {
+ return createEndpoint(
+ path,
+ { method: "POST", body, requireRequest: true },
+ async (context) =>
+ toJsonDto(
+ await run(context.request, (scope) =>
+ operation(scope, context.body),
+ ),
+ ),
+ );
+ };
+}
+
+/** Connect the dynamic plugin registry to the API type declared by its setup factory. */
+export function createPluginMutationFactory(
+ run: RequestRunner,
+ plugin: { id: string; setup: (...args: never[]) => TApi },
+) {
+ const post = createMutationFactory(run);
+ return function mutation<
+ const TPath extends string,
+ TSchema extends z.ZodType,
+ TOutput,
+ >(
+ path: TPath,
+ body: TSchema,
+ operation: (
+ scope: { api: TApi; actorAuthId: string },
+ input: z.output,
+ ) => Promise,
+ ) {
+ if (!path.startsWith(`/${plugin.id}/`))
+ throw new Error(`Plugin endpoints must use /${plugin.id}/.`);
+ return post(path, body, (scope, input) => {
+ const api = scope.hackkit.plugins[plugin.id];
+ if (!api)
+ throw new HackKitError(
+ "NOT_FOUND",
+ `Plugin '${plugin.id}' is not enabled.`,
+ );
+ return operation(
+ { api: api as TApi, actorAuthId: scope.actorAuthId },
+ input,
+ );
+ });
+ };
+}
diff --git a/packages/core/src/plugins.ts b/packages/core/src/plugins.ts
index 6c9b3d08..f3ac7756 100644
--- a/packages/core/src/plugins.ts
+++ b/packages/core/src/plugins.ts
@@ -32,12 +32,10 @@ export type HackKitPlugin<
id: TId;
/** Disable runtime behavior while retaining entities, settings, and permissions. */
enabled?: boolean;
- /** npm package name used by HackKit CLI to resolve routes and actions. */
+ /** npm package name used by the CLI to resolve plugin routes. */
packageName?: string;
- /** Factory exported by the plugin package, e.g. createTeamsActions. */
- actionFactory?: string;
- /** Server action names exposed by the plugin action factory. */
- actionNames?: readonly string[];
+ /** Built once at startup. Endpoint operations receive a fresh authenticated scope. */
+ endpoints?: import("./http/mutation").EndpointFactory;
entities?: readonly EntitySchema[];
permissions?: PermissionMap;
settings?: readonly HackathonSettingDefinition[];
diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts
index ac0c4dea..1ee811fb 100644
--- a/packages/core/tsup.config.ts
+++ b/packages/core/tsup.config.ts
@@ -1,7 +1,14 @@
import { defineConfig } from "tsup";
export default defineConfig({
- entry: ["src/index.ts", "src/client.ts", "src/testing.ts"],
+ entry: {
+ index: "src/index.ts",
+ client: "src/client.ts",
+ testing: "src/testing.ts",
+ "http/index": "src/http/index.ts",
+ "http/client": "src/http/client.ts",
+ "http/event-pass": "src/http/event-pass.ts",
+ },
format: ["cjs", "esm"],
dts: true,
external: ["vitest"],
diff --git a/packages/next/README.md b/packages/next/README.md
index fbe4924d..55ca3199 100644
--- a/packages/next/README.md
+++ b/packages/next/README.md
@@ -1,42 +1,27 @@
# `@hackkit/next`
-Next.js integration for Core authentication, request scopes, page guards, server actions, and HackKit UI.
+Next.js request setup and page guards for HackKit. Core owns the HTTP endpoints and typed path client.
## Runtime setup
-`createHackkitRuntimeFromConfig({ config, afterCurrentUser? })` returns a host with a shared `core` promise and a `getRuntime` function. Register the function with `setHackkitRuntime(host.getRuntime)`. Use [the reference app runtime](../../apps/web/lib/runtime.ts) as the integration example.
+`createHackkitRuntimeFromConfig({ config, afterCurrentUser? })` returns a shared `core` promise, a request-local `getRuntime` factory, and an HTTP `handler`. Register `host.getRuntime` with `setHackkitRuntime` for package-owned plugin pages. The reference implementation is [the app runtime](../../apps/web/lib/runtime.ts).
-The host initializes one Core ORM and Better Auth instance. Each request or action resolves its session from Next headers and creates a fresh EntityManager and domain API scope. React caches the runtime only within a server render. Never store a request runtime, actor, EntityManager, or settings cache in an application singleton.
+The host initializes one Core ORM and Better Auth instance. Each server render resolves its session from Next headers and creates a fresh domain scope. React caches this runtime within that render. HTTP calls resolve the session from their Web Request and run their operation inside Core's native MikroORM RequestContext. Shared endpoint definitions never retain a request's actor or EntityManager.
-`config.database` contains native MikroORM driver options. `config.auth` contains Better Auth options except `database`, which Core owns. `@hackkit/next` adds Better Auth's cookie integration; [the app auth route](../../apps/web/app/api/auth/[...all]/route.ts) delegates to Core's auth handler.
+`config.database` supplies native MikroORM driver options. `config.auth` supplies Better Auth options except its database adapter, which Core owns. The host adds Better Auth's Next cookie integration. `auth.baseURL` must be an explicit URL; HTTP mutation origins use its origin plus the static `auth.trustedOrigins` list. Wildcard and dynamic origin rules are outside this browser contract.
-`getCurrentUser` reads the existing HackKit profile and authoritative auth email. It does not create or overwrite profiles. `afterCurrentUser` optionally performs app-specific work after a profile is loaded.
+Mount `host.handler` as POST at `/api/hackkit/[...path]`. Keep the Better Auth GET/POST route at `/api/auth/[...all]`. The two routes share authentication and connections.
-## UI and routes
+## UI and plugin setup
-Pass `hackKitUIActions` to `HackKitUIProvider`. Named server actions resolve the registered runtime and call its mutations. Keep route overrides serializable; custom route-builder functions belong in a client navigation adapter.
+Create a client with `createHackkitClient` from `@hackkit/core/http/client` and pass it to `HackKitUIProvider`. Components call typed paths, such as `client("@post/users/claim-hack-tag", { body: values })`. Better-call returns `{ data, error }`. Dates in results are ISO strings, and successful empty results contain null. Check `result.error.status` for HTTP failures.
-Use `runtime.pageGuards` in layouts. Common guards include `requireOnboardingAccess`, `requireParticipantAccess`, `requireApprovalPendingAccess`, `requirePermission`, and `requireNotBanned`.
+Plugin sync generates page route reexports. Plugin components import their endpoint type from the plugin's `/client` entry and call `useHackkitClient()`. The hook memoizes a native path client using the provider's base URL, headers, and optional fetch implementation. It requires no generated descriptors or runtime client registration. See the [integration guide](../../docs/guides/next-integration.md) for the full request and response behavior.
-Plugin actions are generated separately by `hackkit plugin sync`. They use each plugin's `/actions` entrypoint and resolve the same request runtime. Server plugin entrypoints remain usable by the CLI without importing Next.
+Use `runtime.pageGuards` in server layouts. Common guards include `requireOnboardingAccess`, `requireParticipantAccess`, `requireApprovalPendingAccess`, `requirePermission`, and `requireNotBanned`. `getCurrentUser` reads the saved profile and optionally runs `afterCurrentUser`.
-## Public API
+## Adding a browser operation
-| Export | Role |
-| ----------------------------------------- | ---------------------------------------------------------- |
-| `createHackkitRuntimeFromConfig` | Initializes shared resources and a request runtime factory |
-| `createHackkitRuntime` | Creates one request runtime from an initialized Core host |
-| `setHackkitRuntime` / `getHackkitRuntime` | Registers and resolves the request factory |
-| `hackKitUIActions` | UI provider action map |
-| `createHackKitMutations` | Domain-to-UI mutation bridge used by runtime |
-| `createPageGuards` | Guard factory used by runtime |
-| `actionSuccess` / `actionFailure` | Shared action result helpers |
+Define its path, strict body schema, and operation in Core's endpoint factory, or in the owning plugin's endpoint factory. Actor fields come from the authenticated runner. Keep permission and domain rules in Core or the plugin. Build the package to update its declarations. Call the typed path in the component and handle `result.error` before success effects or refresh.
-## Adding a Core mutation
-
-1. Add the method to `HackKitUIActions` in `@hackkit/ui`.
-2. Implement it in `createHackKitMutations`.
-3. Add a thin server action in `src/actions.ts`.
-4. Include it in `hackKitUIActions` in `src/action-map.ts`.
-
-Package tests check action-map parity, guard behavior, session isolation, anonymous requests, and profile preservation. Run `pnpm --filter @hackkit/next test` and `pnpm --filter @hackkit/next typecheck` after building dependencies.
+There is no action map or app-level wrapper to maintain. Read-only server pages and jobs continue to use Core directly. The initial browser contract supports static POST paths. GET/query endpoints and dynamic path parameters are not implemented.
diff --git a/packages/next/package.json b/packages/next/package.json
index bbab38b1..41504a06 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -23,7 +23,6 @@
"dependencies": {
"@hackkit/config": "workspace:*",
"@hackkit/core": "workspace:*",
- "@hackkit/ui": "workspace:*",
"better-auth": "1.7.6"
},
"peerDependencies": {
diff --git a/packages/next/src/__tests__/action-actor.test.ts b/packages/next/src/__tests__/action-actor.test.ts
deleted file mode 100644
index 08325d96..00000000
--- a/packages/next/src/__tests__/action-actor.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { expect, it } from "vitest";
-import { CorePermission, coreModels } from "@hackkit/core";
-import { createTestHackkit, createTestUser } from "@hackkit/core/testing";
-import { createHackKitMutations } from "../mutations";
-
-it("does not let a submitted actor ID grant a caller administrator permissions", async () => {
- const hackkit = await createTestHackkit();
- for (const authId of ["owner", "caller"]) {
- await createTestUser(hackkit, {
- authId,
- email: `${authId}@example.com`,
- name: `${authId} Test`,
- });
- }
- hackkit.em.create(coreModels.role, {
- id: "owner-role",
- name: "Owner",
- position: 0,
- permissions: [CorePermission.SuperAdmin],
- });
- const owner = await hackkit.em.findOneOrFail(coreModels.user, {
- id: "owner",
- });
- owner.roleId = "owner-role";
- await hackkit.em.flush();
- const mutations = createHackKitMutations({
- hackkit,
- getAuthId: async () => "caller",
- getSettingValue: hackkit.settings.getValue,
- });
- const submitted = {
- actorAuthId: "owner",
- name: "Forged role",
- position: 10,
- permissions: [],
- color: "#ffffff",
- };
- expect(await mutations.createRole(submitted)).toMatchObject({ ok: false });
- expect(
- await hackkit.em.count(coreModels.role, { name: "Forged role" }),
- ).toBe(0);
-});
diff --git a/packages/next/src/__tests__/action-bridge.test.ts b/packages/next/src/__tests__/action-bridge.test.ts
deleted file mode 100644
index 9e10d23d..00000000
--- a/packages/next/src/__tests__/action-bridge.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { HackKitUIActions } from "@hackkit/ui";
-import { hackKitUIActions } from "../action-map";
-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.
- */
-const EXPECTED_ACTION_KEYS = [
- "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",
-] as const satisfies readonly (keyof HackKitUIActions)[];
-
-type MissingFromExpected = Exclude<
- keyof HackKitUIActions,
- (typeof EXPECTED_ACTION_KEYS)[number]
->;
-type ExtraInExpected = Exclude<
- (typeof EXPECTED_ACTION_KEYS)[number],
- keyof HackKitUIActions
->;
-
-const _assertNoMissingKeys: MissingFromExpected extends never ? true : never =
- true;
-const _assertNoExtraKeys: ExtraInExpected extends never ? true : never = true;
-
-void _assertNoMissingKeys;
-void _assertNoExtraKeys;
-
-function sortedKeys(value: object): string[] {
- return Object.keys(value).sort();
-}
-
-const expectedSorted = [...EXPECTED_ACTION_KEYS].sort();
-
-describe("Core action bridge", () => {
- it("exposes every HackKitUIActions key on hackKitUIActions", () => {
- expect(sortedKeys(hackKitUIActions)).toEqual(expectedSorted);
- });
-
- it("exposes every HackKitUIActions key from createHackKitMutations", () => {
- const mutations = createHackKitMutations({
- hackkit: {} as never,
- getAuthId: async () => "auth-id",
- getSettingValue: async () => true,
- });
- 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", () => {
- for (const key of EXPECTED_ACTION_KEYS) {
- expect(typeof hackKitUIActions[key], key).toBe("function");
- }
- });
-});
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 71b9f0f4..00000000
--- a/packages/next/src/actions.ts
+++ /dev/null
@@ -1,166 +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
- )(...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/index.ts b/packages/next/src/index.ts
index 8f59c32c..6eadbcc2 100644
--- a/packages/next/src/index.ts
+++ b/packages/next/src/index.ts
@@ -10,9 +10,5 @@ export type {
HackkitRuntime,
HackkitRuntimeHost,
} from "./runtime";
-export { actionFailure, actionSuccess } from "@hackkit/ui/actions";
-export type { HackKitActionResult } from "@hackkit/ui/actions";
-export { createHackKitMutations } from "./mutations";
-export { hackKitUIActions } from "./action-map";
export { createPageGuards } from "./page-guards";
export type { PageGuardOptions, PageGuards } from "./page-guards";
diff --git a/packages/next/src/mutations.ts b/packages/next/src/mutations.ts
deleted file mode 100644
index b33c158d..00000000
--- a/packages/next/src/mutations.ts
+++ /dev/null
@@ -1,434 +0,0 @@
-import {
- CorePermission,
- CoreSetting,
- HackKitError,
- type HackKit,
- type SettingKey,
- type SettingValue,
-} from "@hackkit/core";
-import type { HackkitRuntime } from "./runtime";
-
-function isHackkitRuntime(
- value: CreateHackKitMutationsOptions | HackkitRuntime,
-): value is HackkitRuntime {
- return "pageGuards" in value && "getCurrentUser" in value;
-}
-import { actionFailure, actionSuccess } from "@hackkit/ui/actions";
-import type { HackKitActionResult } from "@hackkit/ui/actions";
-import {
- type EventFormValues,
- type HackKitUIActions,
- type AssignRoleInput,
- type ApproveUserInput,
- type BanUserInput,
- type CheckInUserInput,
- type CreateRoleInput,
- type HackTagFormValues,
- type HackerRegistrationFormValues,
- type PreviewEventPassQrInput,
- type PreviewEventPassQrResult,
- type RecordEventScanInput,
- type SetRsvpStatusInput,
- type UpdateRoleInput,
- type UserProfileFormValues,
- type UserDataFormValues,
- resolveEventPassTargetAuthId,
-} from "@hackkit/ui";
-
-function parseEventFormValues(values: EventFormValues) {
- return {
- title: values.title,
- description: values.description,
- startTime: new Date(values.startTime),
- endTime: new Date(values.endTime),
- location: values.location,
- type: values.type,
- host: values.host.trim() ? values.host.trim() : undefined,
- hidden: values.hidden,
- };
-}
-
-export type CreateHackKitMutationsOptions = {
- hackkit: HackKit;
- getAuthId: () => Promise;
- getSettingValue: (key: SettingKey) => Promise;
-};
-
-export function createHackKitMutations(
- runtime: HackkitRuntime,
-): HackKitUIActions;
-export function createHackKitMutations(
- options: CreateHackKitMutationsOptions,
-): HackKitUIActions;
-export function createHackKitMutations(
- optionsOrRuntime: CreateHackKitMutationsOptions | HackkitRuntime,
-): HackKitUIActions {
- const options = isHackkitRuntime(optionsOrRuntime)
- ? {
- hackkit: optionsOrRuntime.hackkit,
- getAuthId: optionsOrRuntime.getAuthId,
- getSettingValue: optionsOrRuntime.getSettingValue,
- }
- : optionsOrRuntime;
- const { hackkit, getAuthId, getSettingValue } = options;
- const now = () => new Date();
-
- async function resolveTargetFromQr(rawQr: string) {
- const eventPassQrTtlMs = await getSettingValue(
- CoreSetting.EventPassQrTtlMs,
- );
- return resolveEventPassTargetAuthId(
- rawQr,
- now(),
- Number(eventPassQrTtlMs),
- );
- }
-
- return {
- async completeUserData(values: UserDataFormValues) {
- try {
- const authId = await getAuthId();
- await hackkit.userData.completeUserData({
- ...values,
- authId,
- });
- return actionSuccess();
- } catch (error) {
- return actionFailure(error, "Could not save user data.");
- }
- },
-
- async claimHackTag(values: HackTagFormValues) {
- try {
- const authId = await getAuthId();
- await hackkit.users.claimHackTag({
- ...values,
- authId,
- });
- return actionSuccess();
- } catch (error) {
- return actionFailure(error, "Could not claim HackTag.");
- }
- },
-
- async updateUserProfile(values: UserProfileFormValues) {
- try {
- const authId = await getAuthId();
- const user = await hackkit.users.updateProfile({
- ...values,
- authId,
- });
- return actionSuccess(user);
- } catch (error) {
- return actionFailure(error, "Could not update profile.");
- }
- },
-
- async registerHacker(values: HackerRegistrationFormValues) {
- try {
- const userId = await getAuthId();
- await hackkit.hackers.registerHacker({
- ...values,
- userId,
- });
- return actionSuccess();
- } catch (error) {
- return actionFailure(
- error,
- "Could not complete hacker registration.",
- );
- }
- },
-
- async createEvent(values: EventFormValues) {
- try {
- const actorAuthId = await getAuthId();
- const event = await hackkit.events.createEvent({
- actorAuthId,
- ...parseEventFormValues(values),
- });
- return actionSuccess(event);
- } catch (error) {
- return actionFailure(error, "Could not create event.");
- }
- },
-
- async updateEvent(eventId: string, values: EventFormValues) {
- try {
- const actorAuthId = await getAuthId();
- const event = await hackkit.events.updateEvent({
- actorAuthId,
- eventId,
- ...parseEventFormValues(values),
- });
- return actionSuccess(event);
- } catch (error) {
- return actionFailure(error, "Could not update event.");
- }
- },
-
- async deleteEvent(eventId: string) {
- try {
- const actorAuthId = await getAuthId();
- await hackkit.events.deleteEvent({
- actorAuthId,
- eventId,
- });
- return actionSuccess();
- } catch (error) {
- return actionFailure(error, "Could not delete event.");
- }
- },
-
- async previewEventPassQr(
- input: PreviewEventPassQrInput,
- ): Promise> {
- try {
- const actorAuthId = await getAuthId();
- const targetAuthId = await resolveTargetFromQr(input.rawQr);
- const permission = input.eventId
- ? CorePermission.EventsScan
- : CorePermission.UsersCheckIn;
- await hackkit.accessControl.requirePermission(
- actorAuthId,
- permission,
- );
-
- const user = await hackkit.users.getUser(targetAuthId);
- if (!user) {
- throw new HackKitError("NOT_FOUND", "User not found.");
- }
-
- const priorScans = input.eventId
- ? await hackkit.events.listEventScans({
- actorAuthId,
- eventId: input.eventId,
- targetAuthId,
- })
- : [];
-
- return actionSuccess({ user, priorScans });
- } catch (error) {
- return actionFailure(
- error,
- "Could not read Event Pass QR code.",
- );
- }
- },
-
- async checkInUser(input: CheckInUserInput) {
- try {
- const actorAuthId = await getAuthId();
- const targetAuthId = await resolveTargetFromQr(input.rawQr);
- const updated = await hackkit.users.checkIn({
- actorAuthId,
- targetAuthId,
- });
- return actionSuccess(updated);
- } catch (error) {
- return actionFailure(error, "Could not check in participant.");
- }
- },
-
- async recordEventScan(input: RecordEventScanInput) {
- try {
- const actorAuthId = await getAuthId();
- const targetAuthId = await resolveTargetFromQr(input.rawQr);
- const result = await hackkit.events.recordEventScan({
- actorAuthId,
- eventId: input.eventId,
- targetAuthId,
- });
- return actionSuccess(result);
- } catch (error) {
- return actionFailure(error, "Could not record scan.");
- }
- },
-
- async listSettings() {
- try {
- const actorAuthId = await getAuthId();
- const settings = await hackkit.settings.list({ actorAuthId });
- return actionSuccess(settings);
- } catch (error) {
- return actionFailure(error, "Could not load settings.");
- }
- },
-
- async setSettings(values) {
- try {
- const actorAuthId = await getAuthId();
- const settings = await hackkit.settings.setMany({
- actorAuthId,
- values,
- });
- return actionSuccess(settings);
- } catch (error) {
- return actionFailure(error, "Could not save settings.");
- }
- },
-
- async resetSetting(key) {
- try {
- const actorAuthId = await getAuthId();
- const setting = await hackkit.settings.reset({
- actorAuthId,
- key,
- });
- return actionSuccess(setting);
- } catch (error) {
- return actionFailure(error, "Could not reset setting.");
- }
- },
-
- async clearCheckIn(targetAuthId: string) {
- try {
- const actorAuthId = await getAuthId();
- const updated = await hackkit.users.clearCheckIn({
- actorAuthId,
- targetAuthId,
- });
- return actionSuccess(updated);
- } catch (error) {
- return actionFailure(error, "Could not clear check-in.");
- }
- },
-
- async confirmRsvp() {
- try {
- const authId = await getAuthId();
- const rsvp = await hackkit.rsvp.confirm({ authId });
- return actionSuccess(rsvp);
- } catch (error) {
- return actionFailure(error, "Could not confirm RSVP.");
- }
- },
-
- async cancelRsvp(targetAuthId: string) {
- try {
- const actorAuthId = await getAuthId();
- const rsvp = await hackkit.rsvp.cancel({
- actorAuthId,
- targetAuthId,
- });
- return actionSuccess(rsvp);
- } catch (error) {
- return actionFailure(error, "Could not cancel RSVP.");
- }
- },
-
- async setRsvpStatus(input: SetRsvpStatusInput) {
- try {
- const actorAuthId = await getAuthId();
- const rsvp = await hackkit.rsvp.setStatus({
- ...input,
- actorAuthId,
- });
- return actionSuccess(rsvp);
- } catch (error) {
- return actionFailure(error, "Could not update RSVP.");
- }
- },
-
- async promoteRsvp(targetAuthId?: string) {
- try {
- const actorAuthId = await getAuthId();
- const rsvp = await hackkit.rsvp.promote({
- actorAuthId,
- targetAuthId,
- });
- return actionSuccess(rsvp);
- } catch (error) {
- return actionFailure(error, "Could not promote RSVP.");
- }
- },
-
- async approveUser(input: ApproveUserInput) {
- try {
- const actorAuthId = await getAuthId();
- const updated = await hackkit.users.approveUser({
- actorAuthId,
- targetAuthId: input.targetAuthId,
- approved: input.approved,
- });
- return actionSuccess(updated);
- } catch (error) {
- return actionFailure(error, "Could not update approval.");
- }
- },
-
- async banUser(input: BanUserInput) {
- try {
- const actorAuthId = await getAuthId();
- await hackkit.users.banUser({
- actorAuthId,
- targetAuthId: input.targetAuthId,
- reason: input.reason,
- });
- return actionSuccess();
- } catch (error) {
- return actionFailure(error, "Could not suspend user.");
- }
- },
-
- async unbanUser(targetAuthId: string) {
- try {
- const actorAuthId = await getAuthId();
- await hackkit.users.unbanUser({ actorAuthId, targetAuthId });
- return actionSuccess();
- } catch (error) {
- return actionFailure(error, "Could not reinstate user.");
- }
- },
-
- async assignRoleToUser(input: AssignRoleInput) {
- try {
- const actorAuthId = await getAuthId();
- const updated = await hackkit.roles.assignRoleToUser({
- actorAuthId,
- targetAuthId: input.targetAuthId,
- roleId: input.roleId,
- });
- return actionSuccess(updated);
- } catch (error) {
- return actionFailure(error, "Could not assign role.");
- }
- },
-
- async createRole(input: CreateRoleInput) {
- try {
- const actorAuthId = await getAuthId();
- const role = await hackkit.roles.createRole({
- ...input,
- actorAuthId,
- });
- return actionSuccess(role);
- } catch (error) {
- return actionFailure(error, "Could not create role.");
- }
- },
-
- async updateRole(input: UpdateRoleInput) {
- try {
- const actorAuthId = await getAuthId();
- const role = await hackkit.roles.updateRole({
- ...input,
- actorAuthId,
- });
- return actionSuccess(role);
- } catch (error) {
- return actionFailure(error, "Could not update role.");
- }
- },
-
- async deleteRole(roleId: string) {
- try {
- const actorAuthId = await getAuthId();
- await hackkit.roles.deleteRole({ actorAuthId, roleId });
- return actionSuccess();
- } catch (error) {
- return actionFailure(error, "Could not delete role.");
- }
- },
- };
-}
diff --git a/packages/next/src/runtime.ts b/packages/next/src/runtime.ts
index ecc0b829..cd51d05a 100644
--- a/packages/next/src/runtime.ts
+++ b/packages/next/src/runtime.ts
@@ -2,48 +2,58 @@ import {
initializeHackkit,
HackKitError,
type HackKit,
+ type HackKitPlugin,
type HackKitRuntime as CoreRuntime,
type SettingKey,
type SettingValue,
type User,
} from "@hackkit/core";
import { resolveHackkitConfig, type HackkitConfig } from "@hackkit/config";
-import type { HackKitUIActions } from "@hackkit/ui";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { nextCookies } from "better-auth/next-js";
import { cache } from "react";
-import { createHackKitMutations } from "./mutations";
+import { createHackkitHandler } from "@hackkit/core/http";
import { createPageGuards, type PageGuards } from "./page-guards";
-export type CreateHackkitRuntimeOptions = {
- core: CoreRuntime;
+export type CreateHackkitRuntimeOptions<
+ TPlugins extends readonly HackKitPlugin[] = readonly HackKitPlugin[],
+> = {
+ core: CoreRuntime;
afterCurrentUser?: (user: User, hackkit: HackKit) => Promise;
};
-export type CreateHackkitRuntimeFromConfigOptions = {
- config: HackkitConfig;
+export type CreateHackkitRuntimeFromConfigOptions<
+ TPlugins extends readonly HackKitPlugin[] = readonly HackKitPlugin[],
+> = {
+ config: HackkitConfig;
afterCurrentUser?: CreateHackkitRuntimeOptions["afterCurrentUser"];
};
-export type HackkitRuntime = {
- hackkit: HackKit;
- mutations: HackKitUIActions;
+export type HackkitRuntime<
+ TPlugins extends readonly HackKitPlugin[] = readonly HackKitPlugin[],
+> = {
+ hackkit: ReturnType["createScope"]>["hackkit"];
pageGuards: PageGuards;
getAuthId(): Promise;
getCurrentUser(): Promise;
getSettingValue(key: SettingKey): Promise;
};
-export type HackkitRuntimeHost = {
- core: Promise;
- getRuntime(): Promise;
+export type HackkitRuntimeHost<
+ TPlugins extends readonly HackKitPlugin[] = readonly HackKitPlugin[],
+> = {
+ core: Promise>;
+ handler(request: Request): Promise;
+ getRuntime(): Promise>;
};
/** Bind domain operations to this request's session and fresh EntityManager. */
-export async function createHackkitRuntime(
- options: CreateHackkitRuntimeOptions,
-): Promise {
+export async function createHackkitRuntime<
+ const TPlugins extends readonly HackKitPlugin[],
+>(
+ options: CreateHackkitRuntimeOptions,
+): Promise> {
const session = await options.core.auth.api.getSession({
headers: await headers(),
});
@@ -65,18 +75,12 @@ export async function createHackkitRuntime(
function getSettingValue(key: SettingKey) {
return hackkit.settings.getValue(key);
}
- const mutations = createHackKitMutations({
- hackkit,
- getAuthId,
- getSettingValue,
- });
const pageGuards = createPageGuards(hackkit, getAuthId, {
getCurrentUser,
getSettingValue,
});
return {
hackkit,
- mutations,
pageGuards,
getAuthId,
getCurrentUser,
@@ -85,10 +89,21 @@ export async function createHackkitRuntime(
}
/** Initialize shared resources once; React caches only within a server render. */
-export function createHackkitRuntimeFromConfig(
- options: CreateHackkitRuntimeFromConfigOptions,
-): HackkitRuntimeHost {
+export function createHackkitRuntimeFromConfig<
+ const TPlugins extends readonly HackKitPlugin[] = [],
+>(
+ options: CreateHackkitRuntimeFromConfigOptions,
+): HackkitRuntimeHost {
const config = resolveHackkitConfig(options.config);
+
+ const baseURL = config.auth.baseURL;
+ if (typeof baseURL !== "string")
+ throw new Error(
+ "HackKit HTTP requires an explicit auth.baseURL origin.",
+ );
+ const trustedOrigins = config.auth.trustedOrigins;
+ if (trustedOrigins && !Array.isArray(trustedOrigins))
+ throw new Error("HackKit HTTP requires a static trustedOrigins list.");
const core = initializeHackkit({
...config,
auth: {
@@ -102,7 +117,24 @@ export function createHackkitRuntimeFromConfig(
afterCurrentUser: options.afterCurrentUser,
}),
);
- return { core, getRuntime };
+ let handler: ReturnType | undefined;
+
+ return {
+ core,
+ getRuntime,
+ handler: async (request) => {
+ const runtime = await core;
+ handler ??= createHackkitHandler({
+ runtime,
+ trustedOrigins: [
+ new URL(baseURL).origin,
+ ...(trustedOrigins ?? []),
+ ],
+ logger: config.logger,
+ });
+ return handler(request);
+ },
+ };
}
let runtimeProvider: (() => Promise) | undefined;
diff --git a/packages/plugin-discord/app/discord/page.tsx b/packages/plugin-discord/app/discord/page.tsx
index a637c8f8..003aebca 100644
--- a/packages/plugin-discord/app/discord/page.tsx
+++ b/packages/plugin-discord/app/discord/page.tsx
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
+import { DiscordSyncButton } from "../../src/components/discord-sync-button";
import { getHackkitRuntime } from "@hackkit/next";
import type { DiscordApi } from "../../src/api";
-import { syncDiscordMemberRoles } from "@/app/hackkit-plugin-actions";
export const dynamic = "force-dynamic";
@@ -44,19 +44,7 @@ export default async function DiscordPage() {
) : null}
-
+
) : (
diff --git a/packages/plugin-discord/app/discord/verify/page.tsx b/packages/plugin-discord/app/discord/verify/page.tsx
index f47954be..f1c3259c 100644
--- a/packages/plugin-discord/app/discord/verify/page.tsx
+++ b/packages/plugin-discord/app/discord/verify/page.tsx
@@ -1,7 +1,6 @@
import { notFound, redirect } from "next/navigation";
import { getHackkitRuntime } from "@hackkit/next";
import type { DiscordApi } from "../../../src/api";
-import { confirmDiscordVerification } from "@/app/hackkit-plugin-actions";
import { DiscordVerifyForm } from "../../../src/components/discord-verify-form";
export const dynamic = "force-dynamic";
@@ -50,7 +49,6 @@ export default async function DiscordVerifyPage({
diff --git a/packages/plugin-discord/package.json b/packages/plugin-discord/package.json
index 910ae7f1..66944289 100644
--- a/packages/plugin-discord/package.json
+++ b/packages/plugin-discord/package.json
@@ -20,10 +20,9 @@
"default": "./app/discord/verify/page.tsx"
},
"./app/*": "./app/*",
- "./actions": {
- "types": "./dist/actions.d.ts",
- "import": "./dist/actions.js",
- "default": "./dist/actions.js"
+ "./client": {
+ "types": "./dist/client.d.ts",
+ "import": "./dist/client.js"
}
},
"files": [
@@ -43,7 +42,8 @@
"@hackkit/ui": "workspace:*",
"sonner": "^1.5.0",
"zod": "^3.25.67",
- "@mikro-orm/core": "7.2.1"
+ "@mikro-orm/core": "7.2.1",
+ "better-call": "1.4.0"
},
"peerDependencies": {
"next": ">=14",
diff --git a/packages/plugin-discord/src/actions.ts b/packages/plugin-discord/src/actions.ts
deleted file mode 100644
index 4f6e21b3..00000000
--- a/packages/plugin-discord/src/actions.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { HackkitRuntime } from "@hackkit/next";
-import { actionFailure, actionSuccess } from "@hackkit/next";
-import type { DiscordApi } from "./api";
-
-export type ConfirmDiscordVerificationInput = {
- code: string;
-};
-
-export function createDiscordActions(runtime: HackkitRuntime) {
- return {
- async confirmDiscordVerification(
- values: ConfirmDiscordVerificationInput,
- ) {
- try {
- const discord = runtime.hackkit.plugins
- .discord as unknown as DiscordApi;
- const authId = await runtime.getAuthId();
- const member = await discord.confirmVerification({
- authId,
- code: values.code,
- });
- return actionSuccess(member);
- } catch (error) {
- return actionFailure(error, "Could not link Discord account.");
- }
- },
-
- async syncDiscordMemberRoles() {
- try {
- const discord = runtime.hackkit.plugins
- .discord as unknown as DiscordApi;
- const authId = await runtime.getAuthId();
- const attempt = await discord.syncMemberRoles({ authId });
- return actionSuccess(attempt);
- } catch (error) {
- return actionFailure(error, "Could not sync Discord roles.");
- }
- },
- };
-}
-
-export type DiscordActions = ReturnType;
diff --git a/packages/plugin-discord/src/client.ts b/packages/plugin-discord/src/client.ts
new file mode 100644
index 00000000..791af632
--- /dev/null
+++ b/packages/plugin-discord/src/client.ts
@@ -0,0 +1,3 @@
+import type { createDiscordEndpoints } from "./endpoints";
+
+export type DiscordEndpoints = ReturnType;
diff --git a/packages/plugin-discord/src/components/discord-sync-button.tsx b/packages/plugin-discord/src/components/discord-sync-button.tsx
new file mode 100644
index 00000000..7efbcb9d
--- /dev/null
+++ b/packages/plugin-discord/src/components/discord-sync-button.tsx
@@ -0,0 +1,27 @@
+"use client";
+import * as React from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { Button, useHackkitClient, showHackkitError } from "@hackkit/ui";
+import type { DiscordEndpoints } from "../client";
+export function DiscordSyncButton() {
+ const client = useHackkitClient();
+ const router = useRouter();
+ const [pending, setPending] = React.useState(false);
+ async function sync() {
+ setPending(true);
+ const result = await client("@post/discord/sync-member-roles");
+ setPending(false);
+ if (result.error) {
+ showHackkitError(result.error);
+ return;
+ }
+ toast.success("Discord roles synced.");
+ router.refresh();
+ }
+ return (
+
+ );
+}
diff --git a/packages/plugin-discord/src/components/discord-verify-form.tsx b/packages/plugin-discord/src/components/discord-verify-form.tsx
index a100787a..ab076bd7 100644
--- a/packages/plugin-discord/src/components/discord-verify-form.tsx
+++ b/packages/plugin-discord/src/components/discord-verify-form.tsx
@@ -3,33 +3,28 @@
import * as React from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
-import type { HackKitActionResult } from "@hackkit/ui";
-import { Button } from "@hackkit/ui";
-import type { ConfirmDiscordVerificationInput } from "../actions";
+import { Button, useHackkitClient, showHackkitError } from "@hackkit/ui";
+import type { DiscordEndpoints } from "../client";
type DiscordVerifyFormProps = {
code: string;
username: string;
- confirmDiscordVerification: (
- values: ConfirmDiscordVerificationInput,
- ) => Promise>;
};
-export function DiscordVerifyForm({
- code,
- username,
- confirmDiscordVerification,
-}: DiscordVerifyFormProps) {
+export function DiscordVerifyForm({ code, username }: DiscordVerifyFormProps) {
+ const client = useHackkitClient();
const router = useRouter();
const [pending, setPending] = React.useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setPending(true);
- const result = await confirmDiscordVerification({ code });
+ const result = await client("@post/discord/confirm-verification", {
+ body: { code },
+ });
setPending(false);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
toast.success("Discord account linked.");
diff --git a/packages/plugin-discord/src/endpoints.ts b/packages/plugin-discord/src/endpoints.ts
new file mode 100644
index 00000000..912cadc2
--- /dev/null
+++ b/packages/plugin-discord/src/endpoints.ts
@@ -0,0 +1,27 @@
+import { z } from "zod";
+import {
+ createPluginMutationFactory,
+ type RequestRunner,
+} from "@hackkit/core/http";
+import { createDiscordApi } from "./api";
+
+export function createDiscordEndpoints(run: RequestRunner) {
+ const post = createPluginMutationFactory(run, {
+ id: "discord",
+ setup: createDiscordApi,
+ });
+ return {
+ confirm_verification: post(
+ "/discord/confirm-verification",
+ z.object({ code: z.string().min(1) }).strict(),
+ async ({ api, actorAuthId }, input) =>
+ api.confirmVerification({ ...input, authId: actorAuthId }),
+ ),
+ sync_member_roles: post(
+ "/discord/sync-member-roles",
+ z.undefined(),
+ async ({ api, actorAuthId }, input) =>
+ api.syncMemberRoles({ authId: actorAuthId }),
+ ),
+ };
+}
diff --git a/packages/plugin-discord/src/index.ts b/packages/plugin-discord/src/index.ts
index 69170cbe..062e39c7 100644
--- a/packages/plugin-discord/src/index.ts
+++ b/packages/plugin-discord/src/index.ts
@@ -1,3 +1,4 @@
+import { createDiscordEndpoints } from "./endpoints";
import type { HackKitPlugin } from "@hackkit/core";
import { createDiscordApi, type DiscordPluginOptions } from "./api";
import { discordModels } from "./models";
@@ -9,8 +10,7 @@ export function discordPlugin(
return {
id: "discord",
packageName: "@hackkit/plugin-discord",
- actionFactory: "createDiscordActions",
- actionNames: ["confirmDiscordVerification", "syncDiscordMemberRoles"],
+ endpoints: createDiscordEndpoints,
entities: Object.values(discordModels),
setup: (context) => {
resolvedOptions ??=
@@ -23,10 +23,6 @@ export function discordPlugin(
export { createDiscordApi } from "./api";
export { discordModels } from "./models";
export { createDiscordHttpRoleSyncProvider } from "./providers";
-export type {
- ConfirmDiscordVerificationInput,
- DiscordActions,
-} from "./actions";
export type {
CreateDiscordVerificationInput,
DiscordApi,
diff --git a/packages/plugin-discord/tsconfig.json b/packages/plugin-discord/tsconfig.json
index a97b9588..08e7ee6d 100644
--- a/packages/plugin-discord/tsconfig.json
+++ b/packages/plugin-discord/tsconfig.json
@@ -6,7 +6,10 @@
"declaration": true,
"declarationMap": true,
"sourceMap": true,
- "noEmit": true
+ "noEmit": true,
+ "module": "ES2022",
+ "moduleResolution": "Bundler",
+ "target": "ES2022"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["dist", "node_modules", "src/**/__tests__/**"]
diff --git a/packages/plugin-discord/tsup.config.ts b/packages/plugin-discord/tsup.config.ts
index 72c19c95..dea2fa68 100644
--- a/packages/plugin-discord/tsup.config.ts
+++ b/packages/plugin-discord/tsup.config.ts
@@ -1,7 +1,7 @@
import { defineConfig } from "tsup";
export default defineConfig({
- entry: ["src/index.ts", "src/actions.ts"],
+ entry: ["src/index.ts", "src/client.ts"],
format: ["cjs", "esm"],
dts: true,
});
diff --git a/packages/plugin-teams/app/invites/page.tsx b/packages/plugin-teams/app/invites/page.tsx
index d8e80271..8834369d 100644
--- a/packages/plugin-teams/app/invites/page.tsx
+++ b/packages/plugin-teams/app/invites/page.tsx
@@ -1,7 +1,6 @@
import { getHackkitRuntime } from "@hackkit/next";
import { notFound, redirect } from "next/navigation";
import type { TeamsApi } from "../../src/api";
-import { respondToInvite } from "@/app/hackkit-plugin-actions";
import { TeamInvites } from "../../src/components/team-invites";
export const dynamic = "force-dynamic";
@@ -33,10 +32,7 @@ export default async function InvitesPage() {
-
+
);
diff --git a/packages/plugin-teams/app/teams/page.tsx b/packages/plugin-teams/app/teams/page.tsx
index 32419932..087307ea 100644
--- a/packages/plugin-teams/app/teams/page.tsx
+++ b/packages/plugin-teams/app/teams/page.tsx
@@ -1,12 +1,6 @@
import { getHackkitRuntime } from "@hackkit/next";
import { notFound, redirect } from "next/navigation";
import type { TeamsApi } from "../../src/api";
-import {
- createTeam,
- inviteToTeam,
- leaveTeam,
- removeMember,
-} from "@/app/hackkit-plugin-actions";
import { TeamCreateForm } from "../../src/components/team-create-form";
import { TeamDashboard } from "../../src/components/team-dashboard";
@@ -50,12 +44,9 @@ export default async function TeamsPage() {
team={team}
currentUser={currentUser}
teamInvites={teamInvites}
- inviteToTeam={inviteToTeam}
- leaveTeam={leaveTeam}
- removeMember={removeMember}
/>
) : (
-
+
)}
diff --git a/packages/plugin-teams/package.json b/packages/plugin-teams/package.json
index a605dee5..050d6ace 100644
--- a/packages/plugin-teams/package.json
+++ b/packages/plugin-teams/package.json
@@ -20,10 +20,9 @@
"default": "./app/invites/page.tsx"
},
"./app/*": "./app/*",
- "./actions": {
- "types": "./dist/actions.d.ts",
- "import": "./dist/actions.js",
- "default": "./dist/actions.js"
+ "./client": {
+ "types": "./dist/client.d.ts",
+ "import": "./dist/client.js"
}
},
"files": [
@@ -43,7 +42,8 @@
"@hackkit/ui": "workspace:*",
"sonner": "^1.5.0",
"zod": "^3.25.67",
- "@mikro-orm/core": "7.2.1"
+ "@mikro-orm/core": "7.2.1",
+ "better-call": "1.4.0"
},
"peerDependencies": {
"next": ">=14",
diff --git a/packages/plugin-teams/src/__tests__/teams.test.ts b/packages/plugin-teams/src/__tests__/teams.test.ts
index 52675a87..f0fae0a2 100644
--- a/packages/plugin-teams/src/__tests__/teams.test.ts
+++ b/packages/plugin-teams/src/__tests__/teams.test.ts
@@ -1,4 +1,3 @@
-import { createTeamsActions } from "../actions";
import {
createTestHackkit,
createTestUser,
@@ -246,40 +245,3 @@ it("keeps the final team place and losing invite consistent across concurrent jo
}),
).toBe(1);
});
-
-it("uses the session actor as team owner even when the submission supplies another actor", async () => {
- const { hackkit } = await createTeamsHackkit();
- await seedHacker(hackkit, "caller", "caller");
- await seedHacker(hackkit, "other", "other");
- const actions = createTeamsActions({
- hackkit,
- getAuthId: async () => "caller",
- });
- const submitted = {
- name: "Session team",
- tag: "session-team",
- actorAuthId: "other",
- };
- const result = await actions.createTeam(submitted);
- expect(result).toMatchObject({ ok: true, data: { ownerAuthId: "caller" } });
-});
-
-it("returns a structured error from generated actions when Teams is disabled", async () => {
- const hackkit = await createTestHackkit({
- plugins: [{ ...teamsPlugin(), enabled: false }],
- });
- const actions = createTeamsActions({
- hackkit,
- getAuthId: async () => "caller",
- });
- expect(
- await actions.createTeam({
- name: "Disabled team",
- tag: "disabled-team",
- }),
- ).toMatchObject({
- ok: false,
- message: "HackKit plugin 'teams' is disabled.",
- });
- expect(await hackkit.em.count(teamsModels.team, {})).toBe(0);
-});
diff --git a/packages/plugin-teams/src/actions.ts b/packages/plugin-teams/src/actions.ts
deleted file mode 100644
index 8c984786..00000000
--- a/packages/plugin-teams/src/actions.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import type { HackkitRuntime } from "@hackkit/next";
-import { actionFailure, actionSuccess } from "@hackkit/next";
-
-export type CreateTeamInput = {
- name: string;
- tag: string;
-};
-
-export type InviteToTeamInput = {
- teamId: string;
- hackTag: string;
-};
-
-export type RespondToInviteInput = {
- inviteId: string;
- accept: boolean;
-};
-
-export type RemoveMemberInput = {
- memberAuthId: string;
-};
-
-import type { TeamsApi } from "./api";
-
-export function createTeamsActions(
- runtime: Pick,
-) {
- return {
- async createTeam(values: CreateTeamInput) {
- try {
- const teams = runtime.hackkit.plugins
- .teams as unknown as TeamsApi;
- const actorAuthId = await runtime.getAuthId();
- const team = await teams.createTeam({
- ...values,
- actorAuthId,
- });
- return actionSuccess(team);
- } catch (error) {
- return actionFailure(error, "Could not create team.");
- }
- },
-
- async inviteToTeam(values: InviteToTeamInput) {
- try {
- const teams = runtime.hackkit.plugins
- .teams as unknown as TeamsApi;
- const actorAuthId = await runtime.getAuthId();
- const invite = await teams.inviteToTeam({
- ...values,
- actorAuthId,
- });
- return actionSuccess(invite);
- } catch (error) {
- return actionFailure(error, "Could not send invite.");
- }
- },
-
- async respondToInvite(values: RespondToInviteInput) {
- try {
- const teams = runtime.hackkit.plugins
- .teams as unknown as TeamsApi;
- const actorAuthId = await runtime.getAuthId();
- const result = await teams.respondToInvite({
- ...values,
- actorAuthId,
- });
- return actionSuccess(result);
- } catch (error) {
- return actionFailure(error, "Could not respond to invite.");
- }
- },
-
- async leaveTeam() {
- try {
- const teams = runtime.hackkit.plugins
- .teams as unknown as TeamsApi;
- const actorAuthId = await runtime.getAuthId();
- await teams.leaveTeam({ actorAuthId });
- return actionSuccess();
- } catch (error) {
- return actionFailure(error, "Could not leave team.");
- }
- },
-
- async removeMember(values: RemoveMemberInput) {
- try {
- const teams = runtime.hackkit.plugins
- .teams as unknown as TeamsApi;
- const actorAuthId = await runtime.getAuthId();
- await teams.removeMember({
- actorAuthId,
- memberAuthId: values.memberAuthId,
- });
- return actionSuccess();
- } catch (error) {
- return actionFailure(error, "Could not remove team member.");
- }
- },
- };
-}
-
-export type TeamsActions = ReturnType;
diff --git a/packages/plugin-teams/src/client.ts b/packages/plugin-teams/src/client.ts
new file mode 100644
index 00000000..e6451008
--- /dev/null
+++ b/packages/plugin-teams/src/client.ts
@@ -0,0 +1,3 @@
+import type { createTeamsEndpoints } from "./endpoints";
+
+export type TeamsEndpoints = ReturnType;
diff --git a/packages/plugin-teams/src/components/team-create-form.tsx b/packages/plugin-teams/src/components/team-create-form.tsx
index da9ec882..d2efd32e 100644
--- a/packages/plugin-teams/src/components/team-create-form.tsx
+++ b/packages/plugin-teams/src/components/team-create-form.tsx
@@ -3,17 +3,11 @@
import * as React from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
-import type { HackKitActionResult } from "@hackkit/ui";
-import { Button } from "@hackkit/ui";
-import type { CreateTeamInput } from "../actions";
+import { Button, useHackkitClient, showHackkitError } from "@hackkit/ui";
+import type { TeamsEndpoints } from "../client";
-type TeamCreateFormProps = {
- createTeam: (
- values: CreateTeamInput,
- ) => Promise>;
-};
-
-export function TeamCreateForm({ createTeam }: TeamCreateFormProps) {
+export function TeamCreateForm() {
+ const client = useHackkitClient();
const router = useRouter();
const [name, setName] = React.useState("");
const [tag, setTag] = React.useState("");
@@ -22,10 +16,12 @@ export function TeamCreateForm({ createTeam }: TeamCreateFormProps) {
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setPending(true);
- const result = await createTeam({ name, tag });
+ const result = await client("@post/teams/create", {
+ body: { name, tag },
+ });
setPending(false);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
toast.success("Team created.");
diff --git a/packages/plugin-teams/src/components/team-dashboard.tsx b/packages/plugin-teams/src/components/team-dashboard.tsx
index 0f35c224..0b5371f2 100644
--- a/packages/plugin-teams/src/components/team-dashboard.tsx
+++ b/packages/plugin-teams/src/components/team-dashboard.tsx
@@ -3,10 +3,9 @@
import * as React from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
-import type { User } from "@hackkit/core";
-import type { HackKitActionResult } from "@hackkit/ui";
-import { Button } from "@hackkit/ui";
-import type { InviteToTeamInput, RemoveMemberInput } from "../actions";
+import type { User } from "@hackkit/core/client";
+import { Button, useHackkitClient, showHackkitError } from "@hackkit/ui";
+import type { TeamsEndpoints } from "../client";
import type { TeamInviteWithInvitee, TeamWithMembers } from "../api";
const INVITE_STATUS_LABEL = new Map([
@@ -28,23 +27,14 @@ type TeamDashboardProps = {
team: TeamWithMembers;
currentUser: User;
teamInvites: TeamInviteWithInvitee[];
- inviteToTeam: (
- values: InviteToTeamInput,
- ) => Promise>;
- leaveTeam: () => Promise>;
- removeMember: (
- values: RemoveMemberInput,
- ) => Promise>;
};
export function TeamDashboard({
team,
currentUser,
teamInvites,
- inviteToTeam,
- leaveTeam,
- removeMember,
}: TeamDashboardProps) {
+ const client = useHackkitClient();
const router = useRouter();
const [hackTag, setHackTag] = React.useState("");
const [pending, setPending] = React.useState(null);
@@ -53,10 +43,12 @@ export function TeamDashboard({
async function handleInvite(event: React.FormEvent) {
event.preventDefault();
setPending("invite");
- const result = await inviteToTeam({ teamId: team.id, hackTag });
+ const result = await client("@post/teams/invite", {
+ body: { teamId: team.id, hackTag },
+ });
setPending(null);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
toast.success("Invite sent.");
@@ -66,10 +58,10 @@ export function TeamDashboard({
async function handleLeave() {
setPending("leave");
- const result = await leaveTeam();
+ const result = await client("@post/teams/leave");
setPending(null);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
toast.success("You left the team.");
@@ -78,10 +70,12 @@ export function TeamDashboard({
async function handleRemove(memberAuthId: string) {
setPending(memberAuthId);
- const result = await removeMember({ memberAuthId });
+ const result = await client("@post/teams/remove-member", {
+ body: { memberAuthId },
+ });
setPending(null);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
toast.success("Member removed.");
diff --git a/packages/plugin-teams/src/components/team-invites.tsx b/packages/plugin-teams/src/components/team-invites.tsx
index dacc002c..0b631c85 100644
--- a/packages/plugin-teams/src/components/team-invites.tsx
+++ b/packages/plugin-teams/src/components/team-invites.tsx
@@ -3,28 +3,27 @@
import * as React from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
-import type { HackKitActionResult } from "@hackkit/ui";
-import { Button } from "@hackkit/ui";
+import { Button, useHackkitClient, showHackkitError } from "@hackkit/ui";
+import type { TeamsEndpoints } from "../client";
import type { PendingTeamInvite } from "../api";
-import type { RespondToInviteInput } from "../actions";
type TeamInvitesProps = {
invites: PendingTeamInvite[];
- respondToInvite: (
- values: RespondToInviteInput,
- ) => Promise>;
};
-export function TeamInvites({ invites, respondToInvite }: TeamInvitesProps) {
+export function TeamInvites({ invites }: TeamInvitesProps) {
+ const client = useHackkitClient();
const router = useRouter();
const [pendingId, setPendingId] = React.useState(null);
async function handleRespond(inviteId: string, accept: boolean) {
setPendingId(inviteId);
- const result = await respondToInvite({ inviteId, accept });
+ const result = await client("@post/teams/respond-to-invite", {
+ body: { inviteId, accept },
+ });
setPendingId(null);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
toast.success(accept ? "Invite accepted." : "Invite declined.");
diff --git a/packages/plugin-teams/src/endpoints.ts b/packages/plugin-teams/src/endpoints.ts
new file mode 100644
index 00000000..1bfaaf79
--- /dev/null
+++ b/packages/plugin-teams/src/endpoints.ts
@@ -0,0 +1,54 @@
+import { z } from "zod";
+import {
+ createPluginMutationFactory,
+ type RequestRunner,
+} from "@hackkit/core/http";
+import { createTeamsApi } from "./api";
+
+export function createTeamsEndpoints(run: RequestRunner) {
+ const post = createPluginMutationFactory(run, {
+ id: "teams",
+ setup: createTeamsApi,
+ });
+ return {
+ create: post(
+ "/teams/create",
+ z
+ .object({ name: z.string().min(1), tag: z.string().min(1) })
+ .strict(),
+ async ({ api, actorAuthId }, input) =>
+ api.createTeam({ ...input, actorAuthId }),
+ ),
+ invite: post(
+ "/teams/invite",
+ z
+ .object({
+ teamId: z.string().min(1),
+ hackTag: z.string().min(1),
+ })
+ .strict(),
+ async ({ api, actorAuthId }, input) =>
+ api.inviteToTeam({ ...input, actorAuthId }),
+ ),
+ respond_to_invite: post(
+ "/teams/respond-to-invite",
+ z
+ .object({ inviteId: z.string().min(1), accept: z.boolean() })
+ .strict(),
+ async ({ api, actorAuthId }, input) =>
+ api.respondToInvite({ ...input, actorAuthId }),
+ ),
+ leave: post(
+ "/teams/leave",
+ z.undefined(),
+ async ({ api, actorAuthId }, input) =>
+ api.leaveTeam({ actorAuthId }),
+ ),
+ remove_member: post(
+ "/teams/remove-member",
+ z.object({ memberAuthId: z.string().min(1) }).strict(),
+ async ({ api, actorAuthId }, input) =>
+ api.removeMember({ ...input, actorAuthId }),
+ ),
+ };
+}
diff --git a/packages/plugin-teams/src/index.ts b/packages/plugin-teams/src/index.ts
index c1784118..ac759178 100644
--- a/packages/plugin-teams/src/index.ts
+++ b/packages/plugin-teams/src/index.ts
@@ -1,3 +1,4 @@
+import { createTeamsEndpoints } from "./endpoints";
import { defineSetting } from "@hackkit/core";
import type { HackKitPlugin } from "@hackkit/core";
import { createTeamsApi } from "./api";
@@ -12,14 +13,7 @@ export function teamsPlugin(): HackKitPlugin<
return {
id: "teams",
packageName: "@hackkit/plugin-teams",
- actionFactory: "createTeamsActions",
- actionNames: [
- "createTeam",
- "inviteToTeam",
- "respondToInvite",
- "leaveTeam",
- "removeMember",
- ],
+ endpoints: createTeamsEndpoints,
entities: Object.values(teamsModels),
settings: [
defineSetting({
@@ -56,4 +50,3 @@ export type {
PendingTeamInvite,
TeamInviteWithInvitee,
} from "./api";
-export type { TeamsActions } from "./actions";
diff --git a/packages/plugin-teams/tsconfig.json b/packages/plugin-teams/tsconfig.json
index a97b9588..08e7ee6d 100644
--- a/packages/plugin-teams/tsconfig.json
+++ b/packages/plugin-teams/tsconfig.json
@@ -6,7 +6,10 @@
"declaration": true,
"declarationMap": true,
"sourceMap": true,
- "noEmit": true
+ "noEmit": true,
+ "module": "ES2022",
+ "moduleResolution": "Bundler",
+ "target": "ES2022"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["dist", "node_modules", "src/**/__tests__/**"]
diff --git a/packages/plugin-teams/tsup.config.ts b/packages/plugin-teams/tsup.config.ts
index 72c19c95..dea2fa68 100644
--- a/packages/plugin-teams/tsup.config.ts
+++ b/packages/plugin-teams/tsup.config.ts
@@ -1,7 +1,7 @@
import { defineConfig } from "tsup";
export default defineConfig({
- entry: ["src/index.ts", "src/actions.ts"],
+ entry: ["src/index.ts", "src/client.ts"],
format: ["cjs", "esm"],
dts: true,
});
diff --git a/packages/ui/package.json b/packages/ui/package.json
index e97aacbf..16a5f999 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -10,10 +10,6 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
- "./actions": {
- "types": "./dist/actions.d.ts",
- "import": "./dist/actions.js"
- },
"./event-pass": {
"types": "./dist/event-pass.d.ts",
"import": "./dist/event-pass.js"
diff --git a/packages/ui/src/actions.ts b/packages/ui/src/actions.ts
deleted file mode 100644
index 2d53b568..00000000
--- a/packages/ui/src/actions.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { HackKitError } from "@hackkit/core/client";
-
-export type HackKitActionResult =
- | { ok: true; data: T }
- | { ok: false; message: string };
-
-export function actionSuccess(data?: T): HackKitActionResult {
- return { ok: true, data: data as T };
-}
-
-export function actionFailure(
- error: unknown,
- fallbackMessage?: string,
-): HackKitActionResult {
- return {
- ok: false,
- message:
- error instanceof HackKitError
- ? error.message
- : (fallbackMessage ?? "Something went wrong."),
- };
-}
diff --git a/packages/ui/src/components/admin-console-controls.tsx b/packages/ui/src/components/admin-console-controls.tsx
index 788aa5e9..3b30448c 100644
--- a/packages/ui/src/components/admin-console-controls.tsx
+++ b/packages/ui/src/components/admin-console-controls.tsx
@@ -1,4 +1,6 @@
"use client";
+import type { ApiResult } from "@hackkit/core/client";
+import { showHackkitError } from "../http-error";
import * as React from "react";
import type {
@@ -52,7 +54,7 @@ export function AdminUserActions({
record: AdminUserRecord;
roles: Role[];
}) {
- const { actions, navigation } = useHackKitUI();
+ const { client, navigation } = useHackKitUI();
const [pendingAction, setPendingAction] =
React.useState(null);
const [roleId, setRoleId] = React.useState(record.user.roleId ?? "");
@@ -60,16 +62,14 @@ export function AdminUserActions({
async function run(
action: PendingAction,
- task: () => Promise<
- { ok: true; data: T } | { ok: false; message: string }
- >,
+ task: () => Promise>,
successMessage: string,
) {
setPendingAction(action);
const result = await task();
setPendingAction(null);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
toast.success(successMessage);
@@ -95,9 +95,11 @@ export function AdminUserActions({
run(
"approval",
() =>
- actions.approveUser({
- targetAuthId: record.user.id,
- approved: !record.user.isApproved,
+ client("@post/users/approve", {
+ body: {
+ targetAuthId: record.user.id,
+ approved: !record.user.isApproved,
+ },
}),
record.user.isApproved
? "Approval removed."
@@ -133,9 +135,11 @@ export function AdminUserActions({
run(
"assignRole",
() =>
- actions.assignRoleToUser({
- targetAuthId: record.user.id,
- roleId,
+ client("@post/roles/assign", {
+ body: {
+ targetAuthId: record.user.id,
+ roleId,
+ },
}),
"Role assigned.",
)
@@ -160,7 +164,13 @@ export function AdminUserActions({
onClick={() =>
run(
"unban",
- () => actions.unbanUser(record.user.id),
+ () =>
+ client("@post/users/unban", {
+ body: {
+ targetAuthId:
+ record.user.id,
+ },
+ }),
"User reinstated.",
)
}
@@ -186,11 +196,14 @@ export function AdminUserActions({
run(
"ban",
() =>
- actions.banUser({
- targetAuthId: record.user.id,
- reason:
- banReason.trim() ||
- undefined,
+ client("@post/users/ban", {
+ body: {
+ targetAuthId:
+ record.user.id,
+ reason:
+ banReason.trim() ||
+ undefined,
+ },
}),
"User suspended.",
)
@@ -216,9 +229,11 @@ export function AdminUserActions({
run(
"rsvpConfirm",
() =>
- actions.setRsvpStatus({
- targetAuthId: record.user.id,
- status: "confirmed",
+ client("@post/rsvp/set-status", {
+ body: {
+ targetAuthId: record.user.id,
+ status: "confirmed",
+ },
}),
"RSVP marked confirmed.",
)
@@ -234,9 +249,11 @@ export function AdminUserActions({
run(
"rsvpWaitlist",
() =>
- actions.setRsvpStatus({
- targetAuthId: record.user.id,
- status: "waitlisted",
+ client("@post/rsvp/set-status", {
+ body: {
+ targetAuthId: record.user.id,
+ status: "waitlisted",
+ },
}),
"RSVP moved to waitlist.",
)
@@ -254,7 +271,12 @@ export function AdminUserActions({
onClick={() =>
run(
"rsvpPromote",
- () => actions.promoteRsvp(record.user.id),
+ () =>
+ client("@post/rsvp/promote", {
+ body: {
+ targetAuthId: record.user.id,
+ },
+ }),
"Waitlisted RSVP promoted.",
)
}
@@ -270,7 +292,12 @@ export function AdminUserActions({
onClick={() =>
run(
"rsvpCancel",
- () => actions.cancelRsvp(record.user.id),
+ () =>
+ client("@post/rsvp/cancel", {
+ body: {
+ targetAuthId: record.user.id,
+ },
+ }),
"RSVP cancelled.",
)
}
@@ -299,7 +326,7 @@ export function AdminRoleManager({
roles: Role[];
permissions?: string[];
}) {
- const { actions, navigation } = useHackKitUI();
+ const { client, navigation } = useHackKitUI();
const [pendingAction, setPendingAction] =
React.useState(null);
const [name, setName] = React.useState("");
@@ -320,15 +347,17 @@ export function AdminRoleManager({
async function createRole(event: React.FormEvent) {
event.preventDefault();
setPendingAction("createRole");
- const result = await actions.createRole({
- name,
- position,
- color: color.trim() || undefined,
- permissions: selectedPermissions,
+ const result = await client("@post/roles/create", {
+ body: {
+ name,
+ position,
+ color: color.trim() || undefined,
+ permissions: selectedPermissions,
+ },
});
setPendingAction(null);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
toast.success("Role created.");
@@ -343,10 +372,12 @@ export function AdminRoleManager({
if (!window.confirm(`Delete ${role.name}?`)) return;
const actionId = `deleteRole:${role.id}` as const;
setPendingAction(actionId);
- const result = await actions.deleteRole(role.id);
+ const result = await client("@post/roles/delete", {
+ body: { roleId: role.id },
+ });
setPendingAction(null);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
toast.success("Role deleted.");
@@ -470,7 +501,7 @@ function RoleEditor({
setPendingAction: (action: PendingAction) => void;
onDelete: (role: Role) => Promise;
}) {
- const { actions, navigation } = useHackKitUI();
+ const { client, navigation } = useHackKitUI();
const [name, setName] = React.useState(role.name);
const [position, setPosition] = React.useState(role.position);
const [color, setColor] = React.useState(role.color ?? "");
@@ -490,16 +521,18 @@ function RoleEditor({
async function updateRole(event: React.FormEvent) {
event.preventDefault();
setPendingAction(actionId);
- const result = await actions.updateRole({
- roleId: role.id,
- name,
- position,
- color: color.trim() || undefined,
- permissions: selectedPermissions,
+ const result = await client("@post/roles/update", {
+ body: {
+ roleId: role.id,
+ name,
+ position,
+ color: color.trim() || undefined,
+ permissions: selectedPermissions,
+ },
});
setPendingAction(null);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
toast.success("Role updated.");
diff --git a/packages/ui/src/components/check-in-scanner.tsx b/packages/ui/src/components/check-in-scanner.tsx
index 46e2315a..77a3ec3d 100644
--- a/packages/ui/src/components/check-in-scanner.tsx
+++ b/packages/ui/src/components/check-in-scanner.tsx
@@ -1,6 +1,7 @@
"use client";
+import { showHackkitError } from "../http-error";
-import type { User } from "@hackkit/core/client";
+import type { User, Wire } from "@hackkit/core/client";
import { Scanner } from "@yudiel/react-qr-scanner";
import * as React from "react";
import { toast } from "sonner";
@@ -17,20 +18,22 @@ import {
} from "./ui/card";
export function CheckInScanner({ className, onDone }: CheckInScannerProps) {
- const { actions, navigation } = useHackKitUI();
+ const { client, navigation } = useHackKitUI();
const [loading, setLoading] = React.useState(false);
const [rawQr, setRawQr] = React.useState(null);
- const [targetUser, setTargetUser] = React.useState(null);
+ const [targetUser, setTargetUser] = React.useState | null>(null);
async function handleScan(rawValue: string) {
if (rawQr) return;
setLoading(true);
- const result = await actions.previewEventPassQr({ rawQr: rawValue });
+ const result = await client("@post/event-pass/preview", {
+ body: { rawQr: rawValue },
+ });
setLoading(false);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
@@ -42,11 +45,13 @@ export function CheckInScanner({ className, onDone }: CheckInScannerProps) {
if (!rawQr) return;
setLoading(true);
- const result = await actions.checkInUser({ rawQr });
+ const result = await client("@post/users/check-in", {
+ body: { rawQr },
+ });
setLoading(false);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
@@ -61,11 +66,15 @@ export function CheckInScanner({ className, onDone }: CheckInScannerProps) {
if (!targetUser) return;
setLoading(true);
- const result = await actions.clearCheckIn(targetUser.id);
+ const result = await client("@post/users/clear-check-in", {
+ body: {
+ targetAuthId: targetUser.id,
+ },
+ });
setLoading(false);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
diff --git a/packages/ui/src/components/event-admin-form.tsx b/packages/ui/src/components/event-admin-form.tsx
index 8475deb6..2e7e62d4 100644
--- a/packages/ui/src/components/event-admin-form.tsx
+++ b/packages/ui/src/components/event-admin-form.tsx
@@ -1,4 +1,5 @@
"use client";
+import { showHackkitError } from "../http-error";
import * as React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -91,7 +92,7 @@ export function EventAdminForm({
successRedirectTo,
className,
}: EventAdminFormProps) {
- const { actions, navigation } = useHackKitUI();
+ const { client, navigation } = useHackKitUI();
const redirectTo = successRedirectTo ?? navigation.routes.admin.events;
const schema = React.useMemo(
() => createEventFormSchema(eventTypes),
@@ -106,12 +107,19 @@ export function EventAdminForm({
});
async function handleSubmit(values: EventFormValues) {
+ const input = {
+ ...values,
+ startTime: new Date(values.startTime).toISOString(),
+ endTime: new Date(values.endTime).toISOString(),
+ };
const result = eventId
- ? await actions.updateEvent(eventId, values)
- : await actions.createEvent(values);
+ ? await client("@post/events/update", {
+ body: { eventId, ...input },
+ })
+ : await client("@post/events/create", { body: input });
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
diff --git a/packages/ui/src/components/event-scanner.tsx b/packages/ui/src/components/event-scanner.tsx
index 84457822..67282880 100644
--- a/packages/ui/src/components/event-scanner.tsx
+++ b/packages/ui/src/components/event-scanner.tsx
@@ -1,6 +1,7 @@
"use client";
+import { showHackkitError } from "../http-error";
-import type { EventScan, User } from "@hackkit/core/client";
+import type { EventScan, User, Wire } from "@hackkit/core/client";
import { Scanner } from "@yudiel/react-qr-scanner";
import * as React from "react";
import { toast } from "sonner";
@@ -17,24 +18,26 @@ import {
} from "./ui/card";
export function EventScanner({ event, className, onDone }: EventScannerProps) {
- const { actions, navigation } = useHackKitUI();
+ const { client, navigation } = useHackKitUI();
const [loading, setLoading] = React.useState(false);
const [rawQr, setRawQr] = React.useState(null);
- const [targetUser, setTargetUser] = React.useState(null);
- const [priorScans, setPriorScans] = React.useState([]);
+ const [targetUser, setTargetUser] = React.useState | null>(null);
+ const [priorScans, setPriorScans] = React.useState[]>([]);
async function handleScan(rawValue: string) {
if (rawQr) return;
setLoading(true);
- const result = await actions.previewEventPassQr({
- rawQr: rawValue,
- eventId: event.id,
+ const result = await client("@post/event-pass/preview", {
+ body: {
+ rawQr: rawValue,
+ eventId: event.id,
+ },
});
setLoading(false);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
@@ -47,14 +50,16 @@ export function EventScanner({ event, className, onDone }: EventScannerProps) {
if (!rawQr) return;
setLoading(true);
- const result = await actions.recordEventScan({
- eventId: event.id,
- rawQr,
+ const result = await client("@post/events/record-scan", {
+ body: {
+ eventId: event.id,
+ rawQr,
+ },
});
setLoading(false);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
diff --git a/packages/ui/src/components/hack-tag-form.tsx b/packages/ui/src/components/hack-tag-form.tsx
index 6824c1ee..dda210bb 100644
--- a/packages/ui/src/components/hack-tag-form.tsx
+++ b/packages/ui/src/components/hack-tag-form.tsx
@@ -1,4 +1,5 @@
"use client";
+import { showHackkitError } from "../http-error";
import * as React from "react";
import { claimHackTagSchema, type User } from "@hackkit/core/client";
@@ -44,7 +45,7 @@ export function HackTagForm({
successRedirectTo,
className,
}: HackTagFormProps) {
- const { actions, navigation } = useHackKitUI();
+ const { client, navigation } = useHackKitUI();
const redirectTo =
successRedirectTo ?? navigation.routes.onboarding.userData;
const form = useForm({
@@ -56,15 +57,18 @@ export function HackTagForm({
const clearPersistedState = usePersistedFormState(form, localStorageKey);
async function onSubmit(values: HackTagFormValues) {
- const result = await actions.claimHackTag(values);
- if (!result.ok) {
- toast.error(result.message);
+ const result = await client("@post/users/claim-hack-tag", {
+ body: values,
+ });
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
clearPersistedState();
toast.success("HackTag claimed.");
navigation.push(redirectTo);
+ navigation.refresh();
}
return (
diff --git a/packages/ui/src/components/hackathon-settings-form.tsx b/packages/ui/src/components/hackathon-settings-form.tsx
index 1e203eae..39f89766 100644
--- a/packages/ui/src/components/hackathon-settings-form.tsx
+++ b/packages/ui/src/components/hackathon-settings-form.tsx
@@ -1,4 +1,5 @@
"use client";
+import { showHackkitError } from "../http-error";
import * as React from "react";
import { toast } from "sonner";
@@ -6,7 +7,8 @@ import { cn } from "../lib/cn";
import { useHackKitUI } from "../provider";
import type { HackathonSettingsFormProps } from "../types";
import type {
- ResolvedHackathonSetting,
+ ResolvedHackathonSetting as DomainSetting,
+ Wire,
SettingKey,
} from "@hackkit/core/client";
import { Button } from "./ui/button";
@@ -21,6 +23,8 @@ import { Checkbox } from "./ui/checkbox";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
+type ResolvedHackathonSetting = DomainSetting | Wire;
+
type FormValue = boolean | string;
type FormState = Record;
@@ -76,8 +80,9 @@ export function HackathonSettingsForm({
settings,
className,
}: HackathonSettingsFormProps) {
- const { actions } = useHackKitUI();
- const [currentSettings, setCurrentSettings] = React.useState(settings);
+ const { client } = useHackKitUI();
+ const [currentSettings, setCurrentSettings] =
+ React.useState(settings);
const [values, setValues] = React.useState(() =>
toFormState(settings),
);
@@ -133,15 +138,17 @@ export function HackathonSettingsForm({
async function saveChanges() {
if (!hasDirty || hasInvalidNumbers) return;
setIsSaving(true);
- const result = await actions.setSettings(
- dirtyUpdates.map((update) => ({
- key: update.key,
- value: update.value,
- })),
- );
+ const result = await client("@post/settings/set", {
+ body: {
+ values: dirtyUpdates.map((update) => ({
+ key: update.key,
+ value: update.value,
+ })),
+ },
+ });
setIsSaving(false);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
setCurrentSettings((existing) =>
@@ -157,10 +164,12 @@ export function HackathonSettingsForm({
async function resetSetting(setting: ResolvedHackathonSetting) {
setResettingKey(setting.key);
- const result = await actions.resetSetting(setting.key);
+ const result = await client("@post/settings/reset", {
+ body: { key: setting.key },
+ });
setResettingKey(null);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
updateSettingInState(result.data);
diff --git a/packages/ui/src/components/hacker-registration-form.tsx b/packages/ui/src/components/hacker-registration-form.tsx
index 9137507e..0bf6b23f 100644
--- a/packages/ui/src/components/hacker-registration-form.tsx
+++ b/packages/ui/src/components/hacker-registration-form.tsx
@@ -1,4 +1,5 @@
"use client";
+import { showHackkitError } from "../http-error";
import * as React from "react";
import { registerHackerSchema, type User } from "@hackkit/core/client";
@@ -126,7 +127,7 @@ export function HackerRegistrationForm({
heardFromOptions,
uploadResume,
}: HackerRegistrationFormProps) {
- const { actions, navigation } = useHackKitUI();
+ const { client, navigation } = useHackKitUI();
const redirectTo = successRedirectTo ?? navigation.routes.dashboard;
const [resumeFile, setResumeFile] = React.useState(null);
const form = useForm({
@@ -165,9 +166,11 @@ export function HackerRegistrationForm({
let result;
try {
- result = await actions.registerHacker({
- ...values,
- resumeUrl: resumeUrl || undefined,
+ result = await client("@post/hackers/register", {
+ body: {
+ ...values,
+ resumeUrl: resumeUrl || undefined,
+ },
});
} catch {
toast.error(
@@ -175,14 +178,15 @@ export function HackerRegistrationForm({
);
return;
}
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
clearPersistedState();
toast.success("Hacker registration complete.");
navigation.push(redirectTo);
+ navigation.refresh();
}
return (
diff --git a/packages/ui/src/components/rsvp-confirmation.tsx b/packages/ui/src/components/rsvp-confirmation.tsx
index 086d97b9..75edea5c 100644
--- a/packages/ui/src/components/rsvp-confirmation.tsx
+++ b/packages/ui/src/components/rsvp-confirmation.tsx
@@ -1,4 +1,6 @@
"use client";
+import type { Rsvp, Wire } from "@hackkit/core/client";
+import { showHackkitError } from "../http-error";
import * as React from "react";
import { toast } from "sonner";
@@ -19,8 +21,10 @@ export function RsvpConfirmation({
summary,
className,
}: RsvpConfirmationProps) {
- const { actions } = useHackKitUI();
- const [currentRsvp, setCurrentRsvp] = React.useState(rsvp);
+ const { client } = useHackKitUI();
+ const [currentRsvp, setCurrentRsvp] = React.useState<
+ Rsvp | Wire | null
+ >(rsvp);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const isConfirmed = currentRsvp?.status === "confirmed";
@@ -35,10 +39,10 @@ export function RsvpConfirmation({
async function confirmRsvp() {
setIsSubmitting(true);
- const result = await actions.confirmRsvp();
+ const result = await client("@post/rsvp/confirm");
setIsSubmitting(false);
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
setCurrentRsvp(result.data);
@@ -112,7 +116,7 @@ function Metric({ label, value }: { label: string; value: React.ReactNode }) {
}
function statusTitle(
- rsvp: RsvpConfirmationProps["rsvp"],
+ rsvp: Rsvp | Wire | null,
summary: RsvpConfirmationProps["summary"],
): string {
if (rsvp?.status === "confirmed") return "You are confirmed.";
@@ -129,7 +133,7 @@ function statusTitle(
}
function statusDescription(
- rsvp: RsvpConfirmationProps["rsvp"],
+ rsvp: Rsvp | Wire | null,
summary: RsvpConfirmationProps["summary"],
): string {
if (rsvp?.status === "confirmed") {
diff --git a/packages/ui/src/components/user-data-form.tsx b/packages/ui/src/components/user-data-form.tsx
index 2d47c8ef..faded727 100644
--- a/packages/ui/src/components/user-data-form.tsx
+++ b/packages/ui/src/components/user-data-form.tsx
@@ -1,4 +1,5 @@
"use client";
+import { showHackkitError } from "../http-error";
import * as React from "react";
import {
@@ -339,7 +340,7 @@ export function UserDataForm({
successRedirectTo,
className,
}: UserDataFormProps) {
- const { actions, navigation } = useHackKitUI();
+ const { client, navigation } = useHackKitUI();
const redirectTo = successRedirectTo ?? navigation.routes.dashboard;
const schema = React.useMemo(
() => createUserDataFormSchema(userDataOptions),
@@ -352,15 +353,18 @@ export function UserDataForm({
const clearPersistedState = usePersistedFormState(form, localStorageKey);
async function onSubmit(values: UserDataFormValues) {
- const result = await actions.completeUserData(values);
- if (!result.ok) {
- toast.error(result.message);
+ const result = await client("@post/user-data/complete", {
+ body: values,
+ });
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
clearPersistedState();
toast.success("User data saved.");
navigation.push(redirectTo);
+ navigation.refresh();
}
return (
diff --git a/packages/ui/src/components/user-profile-settings-form.tsx b/packages/ui/src/components/user-profile-settings-form.tsx
index 0861c786..5a8a9468 100644
--- a/packages/ui/src/components/user-profile-settings-form.tsx
+++ b/packages/ui/src/components/user-profile-settings-form.tsx
@@ -1,7 +1,12 @@
"use client";
+import { showHackkitError } from "../http-error";
import * as React from "react";
-import { updateUserProfileSchema, type User } from "@hackkit/core/client";
+import {
+ updateUserProfileSchema,
+ type User,
+ type Wire,
+} from "@hackkit/core/client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -52,7 +57,7 @@ function textToSkills(value?: string) {
.filter(Boolean);
}
-function userToFormValues(user: User): FormValues {
+function userToFormValues(user: User | Wire): FormValues {
return {
name: user.name,
hackTag: user.hackTag ?? "",
@@ -71,7 +76,7 @@ export function UserProfileSettingsForm({
uploadProfilePhoto,
className,
}: UserProfileSettingsFormProps) {
- const { actions, navigation } = useHackKitUI();
+ const { client, navigation } = useHackKitUI();
const [profilePhotoFile, setProfilePhotoFile] = React.useState(
null,
);
@@ -122,13 +127,15 @@ export function UserProfileSettingsForm({
};
let result;
try {
- result = await actions.updateUserProfile(payload);
+ result = await client("@post/users/update-profile", {
+ body: payload,
+ });
} catch {
toast.error("Could not save your profile. Please try again.");
return;
}
- if (!result.ok) {
- toast.error(result.message);
+ if (result.error) {
+ showHackkitError(result.error);
return;
}
diff --git a/packages/ui/src/event-pass.ts b/packages/ui/src/event-pass.ts
index 23e58f52..22cc65a1 100644
--- a/packages/ui/src/event-pass.ts
+++ b/packages/ui/src/event-pass.ts
@@ -1,92 +1 @@
-import { HackKitError } from "@hackkit/core/client";
-import type { AuthId } from "@hackkit/core/client";
-
-export const DEFAULT_EVENT_PASS_QR_TTL_MS = 5 * 60 * 1000;
-
-export type EventPassQrPayload = {
- authId: AuthId;
- qrIssuedAt: number;
-};
-
-export function createEventPassQrPayload(
- authId: AuthId,
- issuedAt: Date,
-): string {
- const payload: EventPassQrPayload = {
- authId,
- qrIssuedAt: issuedAt.getTime(),
- };
- return JSON.stringify(payload);
-}
-
-export function parseEventPassQrPayload(raw: string): {
- authId: AuthId;
- qrIssuedAt: Date;
-} {
- let parsed: unknown;
- try {
- parsed = JSON.parse(raw);
- } catch {
- throw new HackKitError(
- "VALIDATION_ERROR",
- "Invalid Event Pass QR code.",
- );
- }
-
- if (
- typeof parsed !== "object" ||
- parsed === null ||
- !("authId" in parsed) ||
- !("qrIssuedAt" in parsed) ||
- typeof (parsed as EventPassQrPayload).authId !== "string" ||
- typeof (parsed as EventPassQrPayload).qrIssuedAt !== "number"
- ) {
- throw new HackKitError(
- "VALIDATION_ERROR",
- "Invalid Event Pass QR code.",
- );
- }
-
- const payload = parsed as EventPassQrPayload;
- return {
- authId: payload.authId,
- qrIssuedAt: new Date(payload.qrIssuedAt),
- };
-}
-
-export function validateEventPassQrIssuedAt(
- qrIssuedAt: Date,
- now: Date,
- ttlMs: number,
-): void {
- if (Number.isNaN(qrIssuedAt.getTime())) {
- throw new HackKitError(
- "VALIDATION_ERROR",
- "Invalid Event Pass QR code.",
- );
- }
-
- if (now.getTime() - qrIssuedAt.getTime() > ttlMs) {
- throw new HackKitError(
- "VALIDATION_ERROR",
- "Event Pass QR code has expired. Ask the participant to refresh it.",
- );
- }
-
- if (qrIssuedAt.getTime() > now.getTime() + 60_000) {
- throw new HackKitError(
- "VALIDATION_ERROR",
- "Invalid Event Pass QR code.",
- );
- }
-}
-
-export function resolveEventPassTargetAuthId(
- rawQr: string,
- now: Date,
- ttlMs: number,
-): AuthId {
- const { authId, qrIssuedAt } = parseEventPassQrPayload(rawQr);
- validateEventPassQrIssuedAt(qrIssuedAt, now, ttlMs);
- return authId;
-}
+export * from "@hackkit/core/event-pass";
diff --git a/packages/ui/src/http-error.tsx b/packages/ui/src/http-error.tsx
new file mode 100644
index 00000000..c1b1a319
--- /dev/null
+++ b/packages/ui/src/http-error.tsx
@@ -0,0 +1,31 @@
+"use client";
+import * as React from "react";
+import { toast } from "sonner";
+import type { ApiError } from "@hackkit/core/client";
+
+export function showHackkitError(error: ApiError) {
+ if (error.status === 401) {
+ toast.error(
+ "Your session expired. Sign in, then retry without leaving this form.",
+ {
+ duration: Infinity,
+ description: (
+
+ Sign in in a new tab
+
+ ),
+ },
+ );
+ return;
+ }
+ toast.error(
+ error.statusText === "Fetch Error"
+ ? "Could not reach the server. Please try again."
+ : (error.message ?? "The request failed."),
+ );
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index e67589d4..9c254f6c 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -1,9 +1,8 @@
-export { actionFailure, actionSuccess } from "./actions";
-export type { HackKitActionResult } from "./actions";
export {
HackKitUIProvider,
useHackKitNavigation,
useHackKitUI,
+ useHackkitClient,
} from "./provider";
export type { HackKitUIProviderProps } from "./provider";
export {
@@ -88,27 +87,15 @@ export {
} from "./event-pass";
export type {
CheckInScannerProps,
- CheckInUserInput,
- ApproveUserInput,
- AssignRoleInput,
- BanUserInput,
- CreateRoleInput,
EventAdminFormProps,
EventFormValues,
EventPassProps,
EventScannerProps,
HackathonSettingsFormProps,
- HackKitUIActions,
HackTagFormValues,
HackerRegistrationFormValues,
- PreviewEventPassQrInput,
- PreviewEventPassQrResult,
- RecordEventScanInput,
RsvpConfirmationProps,
ScheduleListProps,
- SetRsvpStatusInput,
- SetSettingsInput,
- UpdateRoleInput,
UserProfileFormValues,
UserDataFormValues,
} from "./types";
@@ -134,3 +121,5 @@ export {
SelectValue,
} from "./components/ui/select";
export { Textarea } from "./components/ui/textarea";
+
+export { showHackkitError } from "./http-error";
diff --git a/packages/ui/src/provider.tsx b/packages/ui/src/provider.tsx
index 83bfa333..9f071d17 100644
--- a/packages/ui/src/provider.tsx
+++ b/packages/ui/src/provider.tsx
@@ -10,10 +10,14 @@ import {
type HackKitNavigation,
type HackKitUIRoutesInput,
} from "./navigation";
-import type { HackKitUIActions } from "./types";
+import {
+ createHackkitClient,
+ type HackkitClient,
+ type HttpEndpoints,
+} from "@hackkit/core/http/client";
type HackKitUIContextValue = {
- actions: HackKitUIActions;
+ client: HackkitClient;
navigation: HackKitNavigation;
eventPassQrTtlMs: number;
};
@@ -23,7 +27,7 @@ const HackKitUIContext = React.createContext(
);
export type HackKitUIProviderProps = {
- actions: HackKitUIActions;
+ client: HackkitClient;
/**
* Full navigation adapter override. When omitted, HackKit UI uses the
* default Next.js App Router adapter with {@link routes} (or defaults).
@@ -39,30 +43,30 @@ export type HackKitUIProviderProps = {
};
type ProviderShellProps = {
- actions: HackKitUIActions;
+ client: HackkitClient;
navigation: HackKitNavigation;
eventPassQrTtlMs: number;
children: React.ReactNode;
};
function HackKitUIProviderShell({
- actions,
+ client,
navigation,
eventPassQrTtlMs,
children,
}: ProviderShellProps) {
return (
{children}
-
+
);
}
function HackKitUIProviderWithDefaultNavigation({
- actions,
+ client,
routes,
eventPassQrTtlMs,
children,
@@ -89,7 +93,7 @@ function HackKitUIProviderWithDefaultNavigation({
return (
@@ -99,7 +103,7 @@ function HackKitUIProviderWithDefaultNavigation({
}
export function HackKitUIProvider({
- actions,
+ client,
navigation,
routes,
eventPassQrTtlMs = DEFAULT_EVENT_PASS_QR_TTL_MS,
@@ -108,7 +112,7 @@ export function HackKitUIProvider({
if (navigation) {
return (
@@ -119,7 +123,7 @@ export function HackKitUIProvider({
return (
@@ -142,3 +146,12 @@ export function useHackKitUI(): HackKitUIContextValue {
export function useHackKitNavigation(): HackKitNavigation {
return useHackKitUI().navigation;
}
+
+/** Bind a plugin's type-only endpoints using the provider's HTTP settings. */
+export function useHackkitClient() {
+ const { options } = useHackKitUI().client;
+ return React.useMemo(
+ () => createHackkitClient(options),
+ [options],
+ );
+}
diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts
index ff24eb9d..598dd697 100644
--- a/packages/ui/src/types.ts
+++ b/packages/ui/src/types.ts
@@ -1,17 +1,11 @@
import type {
Event,
- EventScan,
EventTypes,
- PermissionKey,
ResolvedHackathonSetting,
- Role,
- RoleId,
Rsvp,
RsvpSummary,
- SettingKey,
User,
} from "@hackkit/core/client";
-import type { HackKitActionResult } from "./actions";
export type {
EventTypeOption,
@@ -78,127 +72,6 @@ export type EventFormValues = {
hidden: boolean;
};
-export type PreviewEventPassQrInput = {
- rawQr: string;
- eventId?: string;
-};
-
-export type PreviewEventPassQrResult = {
- user: User;
- priorScans: EventScan[];
-};
-
-export type RecordEventScanInput = {
- eventId: string;
- rawQr: string;
-};
-
-export type CheckInUserInput = {
- rawQr: string;
-};
-
-export type SetSettingsInput = readonly {
- key: SettingKey;
- value: boolean | number;
-}[];
-
-export type ApproveUserInput = {
- targetAuthId: string;
- approved: boolean;
-};
-
-export type BanUserInput = {
- targetAuthId: string;
- reason?: string;
-};
-
-export type AssignRoleInput = {
- targetAuthId: string;
- roleId: RoleId;
-};
-
-export type SetRsvpStatusInput = {
- targetAuthId: string;
- status: Rsvp["status"];
-};
-
-export type CreateRoleInput = {
- id?: RoleId;
- name: string;
- position: number;
- permissions: PermissionKey[];
- color?: string;
-};
-
-export type UpdateRoleInput = {
- roleId: RoleId;
- name?: string;
- position?: number;
- permissions?: PermissionKey[];
- color?: string;
-};
-
-export type HackKitUIActions = {
- completeUserData: (
- values: UserDataFormValues,
- ) => Promise;
- claimHackTag: (values: HackTagFormValues) => Promise;
- updateUserProfile: (
- values: UserProfileFormValues,
- ) => Promise>;
- registerHacker: (
- values: HackerRegistrationFormValues,
- ) => Promise;
- createEvent: (
- values: EventFormValues,
- ) => Promise>;
- updateEvent: (
- eventId: string,
- values: EventFormValues,
- ) => Promise>;
- deleteEvent: (eventId: string) => Promise;
- previewEventPassQr: (
- input: PreviewEventPassQrInput,
- ) => Promise>;
- recordEventScan: (input: RecordEventScanInput) => Promise<
- HackKitActionResult<{
- scan: EventScan;
- priorScans: EventScan[];
- hadPriorScans: boolean;
- }>
- >;
- checkInUser: (
- input: CheckInUserInput,
- ) => Promise>;
- clearCheckIn: (targetAuthId: string) => Promise>;
- confirmRsvp: () => Promise>;
- cancelRsvp: (targetAuthId: string) => Promise>;
- setRsvpStatus: (
- input: SetRsvpStatusInput,
- ) => Promise>;
- promoteRsvp: (targetAuthId?: string) => Promise>;
- approveUser: (
- input: ApproveUserInput,
- ) => Promise>;
- banUser: (input: BanUserInput) => Promise;
- unbanUser: (targetAuthId: string) => Promise;
- assignRoleToUser: (
- input: AssignRoleInput,
- ) => Promise>;
- createRole: (input: CreateRoleInput) => Promise>;
- updateRole: (input: UpdateRoleInput) => Promise>;
- deleteRole: (roleId: RoleId) => Promise;
- listSettings: () => Promise<
- HackKitActionResult
- >;
- setSettings: (
- values: SetSettingsInput,
- ) => Promise>;
- resetSetting: (
- key: SettingKey,
- ) => Promise>;
-};
-
export type ScheduleListProps = {
events: Event[];
eventTypes: EventTypes;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 86232f35..6cb14604 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -188,9 +188,6 @@ importers:
jiti:
specifier: ^2.4.2
version: 2.4.2
- prettier:
- specifier: ^3.3.3
- version: 3.3.3
devDependencies:
'@mikro-orm/sqlite':
specifier: 7.2.1
@@ -238,6 +235,9 @@ importers:
'@a77ay/better-auth-mikro-orm':
specifier: 0.1.1
version: 0.1.1(@mikro-orm/core@7.2.1)(better-auth@1.7.6)
+ '@better-fetch/fetch':
+ specifier: 1.3.2
+ version: 1.3.2
'@mikro-orm/core':
specifier: 7.2.1
version: 7.2.1
@@ -259,6 +259,9 @@ importers:
better-auth:
specifier: 1.7.6
version: 1.7.6(next@14.2.35)(pg@8.23.0)(react-dom@18.3.1)(react@18.3.1)(vitest@3.2.4)
+ better-call:
+ specifier: 1.4.0
+ version: 1.4.0(zod@3.25.67)
zod:
specifier: ^3.25.67
version: 3.25.67
@@ -281,9 +284,6 @@ importers:
'@hackkit/core':
specifier: workspace:*
version: link:../core
- '@hackkit/ui':
- specifier: workspace:*
- version: link:../ui
better-auth:
specifier: 1.7.6
version: 1.7.6(next@14.2.35)(pg@8.23.0)(react-dom@18.3.1)(react@18.3.1)(vitest@3.2.4)
@@ -324,6 +324,9 @@ importers:
'@mikro-orm/core':
specifier: 7.2.1
version: 7.2.1
+ better-call:
+ specifier: 1.4.0
+ version: 1.4.0(zod@3.25.67)
next:
specifier: '>=14'
version: 14.2.35(react-dom@18.3.1)(react@18.3.1)
@@ -389,6 +392,9 @@ importers:
'@mikro-orm/core':
specifier: 7.2.1
version: 7.2.1
+ better-call:
+ specifier: 1.4.0
+ version: 1.4.0(zod@3.25.67)
next:
specifier: '>=14'
version: 14.2.35(react-dom@18.3.1)(react@18.3.1)
diff --git a/tooling/check-client-bundle.mjs b/tooling/check-client-bundle.mjs
new file mode 100644
index 00000000..83cc3c0e
--- /dev/null
+++ b/tooling/check-client-bundle.mjs
@@ -0,0 +1,34 @@
+import assert from "node:assert/strict";
+import { createRequire } from "node:module";
+import { resolve } from "node:path";
+const require = createRequire(resolve("packages/core/package.json"));
+const vitestRequire = createRequire(require.resolve("vitest"));
+const { build } = createRequire(vitestRequire.resolve("vite"))("esbuild");
+const result = await build({
+ entryPoints: ["apps/web/app/providers.tsx"],
+ bundle: true,
+ platform: "browser",
+ format: "esm",
+ minify: true,
+ write: false,
+ metafile: true,
+ logLevel: "silent",
+});
+const files = Object.keys(result.metafile.inputs);
+const forbidden = files.filter((file) =>
+ /(?:@mikro-orm|better-auth|\/core\/(?:src|dist)\/(?:mikro|models|hackkit|plugins)|\/(?:endpoints|api|http-server|handler)\.js$)/.test(
+ file,
+ ),
+);
+assert.deepEqual(
+ forbidden,
+ [],
+ "Server implementations entered the browser client bundle.",
+);
+console.log(
+ JSON.stringify({
+ browserBundleBytes: result.outputFiles[0].contents.length,
+ modules: files.length,
+ serverModules: forbidden.length,
+ }),
+);