@pithy-sh/matchmaking 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ /** The state of a friend edge. */
8
+ export const FriendStatus = z
9
+ .enum(["pending", "accepted"])
10
+ .describe("A friend edge's state: a request awaiting acceptance, or a mutual connection.");
11
+ export type FriendStatus = z.infer<typeof FriendStatus>;
12
+
13
+ /**
14
+ * One edge of the symmetric friend graph — the row in `pithy_matchmaking_friends`. A pair is stored once,
15
+ * canonicalized so `userA < userB`; `requestedBy` records who initiated a pending request (the other
16
+ * accepts). Either party can remove an accepted edge. The unique `(userA, userB)` index makes the pairing
17
+ * idempotent.
18
+ */
19
+ export const Friendship = z
20
+ .object({
21
+ id: z.number().int().describe("Autoincrement PK. Internal only."),
22
+ userA: z.string().describe("The lexicographically smaller user id of the pair (canonical ordering)."),
23
+ userB: z.string().describe("The lexicographically larger user id of the pair (canonical ordering)."),
24
+ status: FriendStatus.describe("Whether the edge is a pending request or an accepted friendship."),
25
+ requestedBy: z.string().describe("Which of the two users sent the request — the other one accepts it."),
26
+ createdAt: SQLiteDate.describe("When the request was sent."),
27
+ updatedAt: SQLiteDate.describe("When the edge last changed (e.g. on acceptance)."),
28
+ })
29
+ .describe("One symmetric friend edge between two users.");
30
+ export type Friendship = z.output<typeof Friendship>;
31
+ export type FriendshipRow = z.input<typeof Friendship>;
@@ -0,0 +1,34 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ /** The lifecycle of a direct invite. */
8
+ export const InviteStatus = z
9
+ .enum(["pending", "accepted", "declined"])
10
+ .describe("A direct invite's state: awaiting a response, accepted (a session was minted), or declined.");
11
+ export type InviteStatus = z.infer<typeof InviteStatus>;
12
+
13
+ /**
14
+ * A direct invite — one player asks another (by email or screen name, resolved to a user id) to play. The
15
+ * row in `pithy_matchmaking_invites`. The id is a UUID (externally referenced in URLs), not an
16
+ * autoincrement, to prevent enumeration.
17
+ */
18
+ export const Invite = z
19
+ .object({
20
+ id: z.string().describe("The invite's UUID — the externally-referenced id."),
21
+ gameKey: z.string().describe("The matchmaking game this invite is for."),
22
+ inviterId: z.string().describe("The authenticated user who sent the invite."),
23
+ inviteeId: z.string().describe("The resolved user the invite was sent to."),
24
+ status: InviteStatus.describe("The invite's current state."),
25
+ sessionId: z
26
+ .string()
27
+ .nullable()
28
+ .describe("The multiplayer session id minted on accept, or null while pending/declined."),
29
+ createdAt: SQLiteDate.describe("When the invite was sent."),
30
+ respondedAt: SQLiteDate.nullable().describe("When the invitee accepted or declined, or null while pending."),
31
+ })
32
+ .describe("One direct invite from one player to another.");
33
+ export type Invite = z.output<typeof Invite>;
34
+ export type InviteRow = z.input<typeof Invite>;
@@ -0,0 +1,38 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
6
+ import type { Kysely } from "kysely";
7
+ import type { z } from "zod";
8
+ import { Friendship } from "./friend";
9
+ import { Invite } from "./invite";
10
+
11
+ /**
12
+ * The matchmaking capability's D1 tables: `pithy_matchmaking_invites` (direct invites) and
13
+ * `pithy_matchmaking_friends` (the friend graph). Room codes live in KV, not D1. Table names are
14
+ * camelCase constants; core's `createDatabase` installs the mandatory `CamelCasePlugin`.
15
+ */
16
+
17
+ export const MATCHMAKING_INVITES_TABLE = "pithyMatchmakingInvites";
18
+ export const MATCHMAKING_FRIENDS_TABLE = "pithyMatchmakingFriends";
19
+
20
+ export function matchmakingTables(): Record<string, z.ZodObject> {
21
+ return {
22
+ [MATCHMAKING_INVITES_TABLE]: Invite,
23
+ [MATCHMAKING_FRIENDS_TABLE]: Friendship,
24
+ };
25
+ }
26
+
27
+ type MatchmakingTables = {
28
+ [MATCHMAKING_INVITES_TABLE]: typeof Invite;
29
+ [MATCHMAKING_FRIENDS_TABLE]: typeof Friendship;
30
+ };
31
+ export type MatchmakingDatabase = Kysely<DatabaseSchema<MatchmakingTables>>;
32
+
33
+ export function matchmakingDatabase(d1: D1Database): MatchmakingDatabase {
34
+ return createDatabase(d1, {
35
+ [MATCHMAKING_INVITES_TABLE]: Invite,
36
+ [MATCHMAKING_FRIENDS_TABLE]: Friendship,
37
+ }) as unknown as MatchmakingDatabase;
38
+ }
@@ -0,0 +1,165 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
6
+
7
+ /**
8
+ * `@pithy-sh/matchmaking` throw sugar. The `matchmaking/*` codes live in core's closed `KitErrorPayload`
9
+ * union; these subclasses set one of those members — the leaderboard/multiplayer pattern. Runtime code
10
+ * throws one of these, never a plain `new Error`.
11
+ */
12
+ interface MatchmakingErrorArgs {
13
+ message?: string;
14
+ action?: string;
15
+ detail?: string;
16
+ /**
17
+ * Values a translating client interpolates into its own wording for this code. Client-facing, so —
18
+ * unlike `action` and `detail` — these cross the boundary with `message`.
19
+ */
20
+ params?: MessageParams;
21
+ }
22
+
23
+ export class MatchmakingRoomNotFoundError extends PithyError {
24
+ constructor(args: MatchmakingErrorArgs = {}, options?: { cause?: unknown }) {
25
+ super(
26
+ {
27
+ code: "matchmaking/room_not_found",
28
+ status: 404,
29
+ message: args.message ?? "That room does not exist.",
30
+ action: args.action ?? "Ask the host for a fresh code — it may have expired or been used up.",
31
+ detail: args.detail,
32
+ params: args.params,
33
+ },
34
+ options,
35
+ );
36
+ }
37
+ }
38
+
39
+ export class MatchmakingRoomFullError extends PithyError {
40
+ constructor(args: MatchmakingErrorArgs = {}, options?: { cause?: unknown }) {
41
+ super(
42
+ {
43
+ code: "matchmaking/room_full",
44
+ status: 409,
45
+ message: args.message ?? "That room code is used up.",
46
+ action: args.action ?? "Ask the host to open a new room.",
47
+ detail: args.detail,
48
+ params: args.params,
49
+ },
50
+ options,
51
+ );
52
+ }
53
+ }
54
+
55
+ export class MatchmakingInvalidCodeError extends PithyError {
56
+ constructor(args: MatchmakingErrorArgs = {}, options?: { cause?: unknown }) {
57
+ super(
58
+ {
59
+ code: "matchmaking/invalid_code",
60
+ status: 400,
61
+ message: args.message ?? "That is not a valid room code.",
62
+ action: args.action ?? "Enter the code exactly as shared, e.g. WXYZ-1234.",
63
+ detail: args.detail,
64
+ params: args.params,
65
+ },
66
+ options,
67
+ );
68
+ }
69
+ }
70
+
71
+ export class MatchmakingInviteNotFoundError extends PithyError {
72
+ constructor(args: MatchmakingErrorArgs = {}, options?: { cause?: unknown }) {
73
+ super(
74
+ {
75
+ code: "matchmaking/invite_not_found",
76
+ status: 404,
77
+ message: args.message ?? "That invite does not exist.",
78
+ action: args.action ?? "It may have been accepted, declined, or expired.",
79
+ detail: args.detail,
80
+ params: args.params,
81
+ },
82
+ options,
83
+ );
84
+ }
85
+ }
86
+
87
+ export class MatchmakingInviteForbiddenError extends PithyError {
88
+ constructor(args: MatchmakingErrorArgs = {}, options?: { cause?: unknown }) {
89
+ super(
90
+ {
91
+ code: "matchmaking/invite_forbidden",
92
+ status: 403,
93
+ message: args.message ?? "This invite is not yours to act on.",
94
+ action: args.action ?? "Only the inviter or the invitee may act on an invite.",
95
+ detail: args.detail,
96
+ params: args.params,
97
+ },
98
+ options,
99
+ );
100
+ }
101
+ }
102
+
103
+ export class MatchmakingUserNotFoundError extends PithyError {
104
+ constructor(args: MatchmakingErrorArgs = {}, options?: { cause?: unknown }) {
105
+ super(
106
+ {
107
+ code: "matchmaking/user_not_found",
108
+ status: 404,
109
+ message: args.message ?? "No single user matches that invite.",
110
+ action: args.action ?? "Invite by email for a unique identity — a display name may be ambiguous.",
111
+ detail: args.detail,
112
+ params: args.params,
113
+ },
114
+ options,
115
+ );
116
+ }
117
+ }
118
+
119
+ export class MatchmakingAlreadyFriendsError extends PithyError {
120
+ constructor(args: MatchmakingErrorArgs = {}, options?: { cause?: unknown }) {
121
+ super(
122
+ {
123
+ code: "matchmaking/already_friends",
124
+ status: 409,
125
+ message: args.message ?? "You are already connected, or a request is already pending.",
126
+ action: args.action ?? "Wait for the pending request, or you are already friends.",
127
+ detail: args.detail,
128
+ params: args.params,
129
+ },
130
+ options,
131
+ );
132
+ }
133
+ }
134
+
135
+ export class MatchmakingFriendRequestNotFoundError extends PithyError {
136
+ constructor(args: MatchmakingErrorArgs = {}, options?: { cause?: unknown }) {
137
+ super(
138
+ {
139
+ code: "matchmaking/friend_request_not_found",
140
+ status: 404,
141
+ message: args.message ?? "There is no pending friend request to act on.",
142
+ action: args.action ?? "The request may have been withdrawn or already answered.",
143
+ detail: args.detail,
144
+ params: args.params,
145
+ },
146
+ options,
147
+ );
148
+ }
149
+ }
150
+
151
+ export class MatchmakingNotQueuedError extends PithyError {
152
+ constructor(args: MatchmakingErrorArgs = {}, options?: { cause?: unknown }) {
153
+ super(
154
+ {
155
+ code: "matchmaking/not_queued",
156
+ status: 404,
157
+ message: args.message ?? "You are not in this queue.",
158
+ action: args.action ?? "Enqueue before checking your status or leaving.",
159
+ detail: args.detail,
160
+ params: args.params,
161
+ },
162
+ options,
163
+ );
164
+ }
165
+ }
@@ -0,0 +1,146 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { FriendStatus } from "../data/friend";
6
+ import { Friendship } from "../data/friend";
7
+ import { MATCHMAKING_FRIENDS_TABLE, type MatchmakingDatabase } from "../data/tables";
8
+ import { MatchmakingAlreadyFriendsError, MatchmakingFriendRequestNotFoundError } from "../error/errors";
9
+
10
+ /**
11
+ * The friend graph — a symmetric connection formed by mutual accept. A player sends a request; the
12
+ * recipient accepts (forming the edge) or declines (dropping it); either party can later remove an
13
+ * accepted edge. Pairs are canonicalized (`userA < userB`) so a connection is one row regardless of who
14
+ * asked. See {@link friendStore}.
15
+ */
16
+
17
+ /** One friend edge from a given user's perspective. */
18
+ export interface FriendSummary {
19
+ /** The other user in the edge. */
20
+ userId: string;
21
+ /** Whether the edge is a pending request or an accepted friendship. */
22
+ status: FriendStatus;
23
+ /** For a pending edge: `outgoing` (this user asked) or `incoming` (the other asked). `mutual` once accepted. */
24
+ direction: "incoming" | "outgoing" | "mutual";
25
+ /** When the edge was last updated. */
26
+ since: Date;
27
+ }
28
+
29
+ export interface FriendStore {
30
+ /** Send a friend request from `from` to `to`. Throws `matchmaking/already_friends` if an edge exists. */
31
+ request(from: string, to: string, at: Date): Promise<void>;
32
+ /** `userId` accepts a pending request from `other`. Throws `matchmaking/friend_request_not_found` if none. */
33
+ accept(userId: string, other: string, at: Date): Promise<void>;
34
+ /** `userId` declines/withdraws a pending edge with `other` (drops the row). */
35
+ decline(userId: string, other: string): Promise<void>;
36
+ /** `userId` removes an accepted friendship with `other`. */
37
+ remove(userId: string, other: string): Promise<void>;
38
+ /** Every edge touching `userId`, from their perspective. */
39
+ list(userId: string): Promise<FriendSummary[]>;
40
+ /** Whether `a` and `b` are accepted friends. */
41
+ areFriends(a: string, b: string): Promise<boolean>;
42
+ }
43
+
44
+ /** Order a pair so the row is stored once regardless of who asked: `userA` is the lexicographically smaller id. */
45
+ function canonical(x: string, y: string): { userA: string; userB: string } {
46
+ return x < y ? { userA: x, userB: y } : { userA: y, userB: x };
47
+ }
48
+
49
+ export function friendStore(db: MatchmakingDatabase): FriendStore {
50
+ const findPair = (userA: string, userB: string) =>
51
+ db
52
+ .selectFrom(MATCHMAKING_FRIENDS_TABLE)
53
+ .selectAll()
54
+ .where("userA", "=", userA)
55
+ .where("userB", "=", userB)
56
+ .executeTakeFirst();
57
+
58
+ return {
59
+ async request(from, to, at) {
60
+ if (from === to) {
61
+ throw new ValidationError({
62
+ message: "You cannot send a friend request to yourself.",
63
+ detail: `request called with from === to (${from}).`,
64
+ });
65
+ }
66
+ const { userA, userB } = canonical(from, to);
67
+ const existing = await findPair(userA, userB);
68
+ if (existing) {
69
+ throw new MatchmakingAlreadyFriendsError({
70
+ detail: `A friend edge already exists for (${userA}, ${userB}) with status ${String((existing as { status: unknown }).status)}.`,
71
+ });
72
+ }
73
+ const record = {
74
+ userA,
75
+ userB,
76
+ status: "pending" as const,
77
+ requestedBy: from,
78
+ createdAt: Friendship.shape.createdAt.encode(at),
79
+ updatedAt: Friendship.shape.updatedAt.encode(at),
80
+ };
81
+ await db
82
+ .insertInto(MATCHMAKING_FRIENDS_TABLE)
83
+ // biome-ignore lint/suspicious/noExplicitAny: the record is the schema's `z.input` side; `id` is an autoincrement rowid SQLite assigns.
84
+ .values(record as any)
85
+ .execute();
86
+ },
87
+
88
+ async accept(userId, other, at) {
89
+ const { userA, userB } = canonical(userId, other);
90
+ const row = await findPair(userA, userB);
91
+ const parsed = row ? Friendship.parse(row) : undefined;
92
+ if (parsed?.status !== "pending" || parsed.requestedBy !== other) {
93
+ throw new MatchmakingFriendRequestNotFoundError({
94
+ detail: `No pending request from ${other} to ${userId} to accept.`,
95
+ });
96
+ }
97
+ await db
98
+ .updateTable(MATCHMAKING_FRIENDS_TABLE)
99
+ .set({ status: "accepted", updatedAt: Friendship.shape.updatedAt.encode(at) })
100
+ .where("userA", "=", userA)
101
+ .where("userB", "=", userB)
102
+ .execute();
103
+ },
104
+
105
+ async decline(userId, other) {
106
+ const { userA, userB } = canonical(userId, other);
107
+ await db
108
+ .deleteFrom(MATCHMAKING_FRIENDS_TABLE)
109
+ .where("userA", "=", userA)
110
+ .where("userB", "=", userB)
111
+ .where("status", "=", "pending")
112
+ .execute();
113
+ },
114
+
115
+ async remove(userId, other) {
116
+ const { userA, userB } = canonical(userId, other);
117
+ await db.deleteFrom(MATCHMAKING_FRIENDS_TABLE).where("userA", "=", userA).where("userB", "=", userB).execute();
118
+ },
119
+
120
+ async list(userId) {
121
+ const rows = await db
122
+ .selectFrom(MATCHMAKING_FRIENDS_TABLE)
123
+ .selectAll()
124
+ .where((eb) => eb.or([eb("userA", "=", userId), eb("userB", "=", userId)]))
125
+ .execute();
126
+ return rows.map((row) => {
127
+ const edge = Friendship.parse(row);
128
+ const otherId = edge.userA === userId ? edge.userB : edge.userA;
129
+ const direction = edge.status === "accepted" ? "mutual" : edge.requestedBy === userId ? "outgoing" : "incoming";
130
+ return { userId: otherId, status: edge.status, direction, since: edge.updatedAt };
131
+ });
132
+ },
133
+
134
+ async areFriends(a, b) {
135
+ const { userA, userB } = canonical(a, b);
136
+ const row = await db
137
+ .selectFrom(MATCHMAKING_FRIENDS_TABLE)
138
+ .select("id")
139
+ .where("userA", "=", userA)
140
+ .where("userB", "=", userB)
141
+ .where("status", "=", "accepted")
142
+ .executeTakeFirst();
143
+ return row !== undefined;
144
+ },
145
+ };
146
+ }
@@ -0,0 +1,24 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
5
+ import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { MiddlewareHandler } from "hono";
7
+
8
+ /**
9
+ * Route guard over the `AuthContext` seam. Copied — not imported from `@pithy-sh/auth` — so matchmaking
10
+ * keeps `dependsOn` empty: without auth installed, `c.var.auth` is null and every guarded route is denied
11
+ * rather than open (the leaderboard/multiplayer pattern). Membership everywhere binds to
12
+ * `c.var.auth.userId`, never a client-supplied id.
13
+ */
14
+ export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
15
+ return async (c, next) => {
16
+ if (!c.var.auth) {
17
+ throw new UnauthorizedError({
18
+ message: "Authentication required.",
19
+ action: "Sign in and retry with a valid session or bearer token.",
20
+ });
21
+ }
22
+ await next();
23
+ };
24
+ }