@diffenderfer-games/hub-contract 0.0.0-stage → 2.0.0-next.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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -2
  3. package/dist/index.d.ts +8454 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +4855 -0
  6. package/dist/index.js.map +1 -0
  7. package/package.json +51 -4
  8. package/src/api/analytics.ts +81 -0
  9. package/src/api/auth.ts +77 -0
  10. package/src/api/catalog.ts +171 -0
  11. package/src/api/daily.ts +122 -0
  12. package/src/api/economy/bundle.ts +69 -0
  13. package/src/api/economy/inventory.ts +57 -0
  14. package/src/api/economy/trades.ts +154 -0
  15. package/src/api/economy.ts +15 -0
  16. package/src/api/index.ts +85 -0
  17. package/src/api/leaderboards.ts +151 -0
  18. package/src/api/messages.ts +142 -0
  19. package/src/api/mp/chat.ts +36 -0
  20. package/src/api/mp/parties.ts +108 -0
  21. package/src/api/mp/races.ts +211 -0
  22. package/src/api/mp/rooms.ts +90 -0
  23. package/src/api/mp/snapshot.ts +17 -0
  24. package/src/api/mp.ts +110 -0
  25. package/src/api/notify.ts +111 -0
  26. package/src/api/progress/game-data.ts +189 -0
  27. package/src/api/progress.ts +59 -0
  28. package/src/api/race.ts +47 -0
  29. package/src/api/reports.ts +208 -0
  30. package/src/api/social/config.ts +47 -0
  31. package/src/api/social/endpoints.ts +179 -0
  32. package/src/api/social/people.ts +118 -0
  33. package/src/api/social/presence.ts +95 -0
  34. package/src/api/social.ts +9 -0
  35. package/src/api/version.ts +40 -0
  36. package/src/coerce.ts +50 -0
  37. package/src/common.ts +42 -0
  38. package/src/entities.ts +62 -0
  39. package/src/errors/codes/core.ts +10 -0
  40. package/src/errors/codes/multiplayer.ts +43 -0
  41. package/src/errors/codes/play.ts +5 -0
  42. package/src/errors/codes/social.ts +31 -0
  43. package/src/errors/index.ts +88 -0
  44. package/src/index.ts +16 -0
  45. package/src/limits.ts +100 -0
  46. package/src/logic/daily.ts +54 -0
  47. package/src/logic/index.ts +8 -0
  48. package/src/logic/presence.ts +62 -0
  49. package/src/logic/race.ts +169 -0
  50. package/src/logic/time.ts +81 -0
  51. package/src/manifest/changes.ts +55 -0
  52. package/src/manifest/controls.ts +42 -0
  53. package/src/manifest/fields.ts +89 -0
  54. package/src/manifest/game.ts +132 -0
  55. package/src/manifest/index.ts +12 -0
  56. package/src/manifest/inject.ts +31 -0
  57. package/src/manifest/leaderboards.ts +29 -0
  58. package/src/manifest/multiplayer.ts +71 -0
  59. package/src/manifest/race.ts +93 -0
  60. package/src/middleware.ts +47 -0
  61. package/src/realtime/define.ts +52 -0
  62. package/src/realtime/envelope.ts +67 -0
  63. package/src/realtime/events/multiplayer.ts +8 -0
  64. package/src/realtime/events/parties.ts +31 -0
  65. package/src/realtime/events/races.ts +27 -0
  66. package/src/realtime/events/rooms.ts +48 -0
  67. package/src/realtime/events/social.ts +81 -0
  68. package/src/realtime/index.ts +24 -0
  69. package/src/realtime/ops/empty.ts +8 -0
  70. package/src/realtime/ops/hello.ts +40 -0
  71. package/src/realtime/ops/multiplayer.ts +8 -0
  72. package/src/realtime/ops/parties.ts +13 -0
  73. package/src/realtime/ops/races.ts +99 -0
  74. package/src/realtime/ops/rooms.ts +122 -0
  75. package/src/realtime/ops/social.ts +58 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Social core REST (multiplayer-impl.md §5.1): config, your own social
3
+ * settings, friends and requests, blocks, player cards, recent players and
4
+ * online counts. Every route here is closed to suspended accounts (403
5
+ * `suspended`) except `GET /social/config` and `GET /social/me`, which a
6
+ * suspended player's UI needs to explain what happened.
7
+ */
8
+ import { z } from 'zod';
9
+ import { Ok } from '../../common';
10
+ import { requiredId } from '../../coerce';
11
+ import { ContentRejected, ErrorBody } from '../../errors';
12
+ import { requireActive, requireClaimed, requireUser, session } from '../../middleware';
13
+ import { NotifyPrefs } from '../notify';
14
+ import { SocialConfig } from './config';
15
+ import { Audience, FriendsList, PlayerCard, RecentPlayer, SocialMe } from './people';
16
+ import { Counts } from './presence';
17
+
18
+ /** `{ me }`: your social settings after a change. */
19
+ export const SocialMeResult = z.object({ me: SocialMe });
20
+
21
+ /** `:id`, another player's user id. A malformed id is a 400. */
22
+ const OtherUser = z.object({ id: requiredId('id must be a user id.') });
23
+
24
+ /** An {@link Audience} field, with the old hub's message for anything else. */
25
+ const audienceField = (field: string) => z.enum(Audience.options, { error: `${field} must be friends or nobody.` });
26
+
27
+ /**
28
+ * `POST /social/friends`: who to befriend, by id or by name, and an optional
29
+ * first message. `userId` wins when both are given; with neither, the player
30
+ * is not found (404 `no_player`).
31
+ */
32
+ export const FriendRequestInput = z.object({
33
+ userId: requiredId('userId must be a user id.').nullish(),
34
+ /** A username, case-insensitive. */
35
+ username: z.string().optional(),
36
+ message: z.string().optional(),
37
+ });
38
+
39
+ /** A friend request. */
40
+ export type FriendRequestInput = z.infer<typeof FriendRequestInput>;
41
+
42
+ /** The result of `POST /social/friends`. */
43
+ export const FriendRequestStatus = z.enum(['requested', 'friends']);
44
+
45
+ /** The social core endpoints. */
46
+ export const socialEndpoints = {
47
+ ...session.group({
48
+ getSocialConfig: {
49
+ method: 'GET', path: '/social/config', response: SocialConfig,
50
+ doc: { summary: 'The site-wide social configuration', tags: ['social'] },
51
+ },
52
+ getOnlineCounts: {
53
+ method: 'GET', path: '/social/counts', response: Counts,
54
+ errors: { 403: ErrorBody },
55
+ doc: { summary: 'Anonymous online counts, overall and per game', tags: ['social'] },
56
+ },
57
+ }),
58
+ ...requireUser.group({
59
+ getMySocialProfile: {
60
+ method: 'GET', path: '/social/me',
61
+ response: z.object({
62
+ me: SocialMe,
63
+ notify: NotifyPrefs,
64
+ /** How many browsers have a push subscription for this account. */
65
+ pushSubs: z.number().int().nonnegative(),
66
+ }),
67
+ errors: { 401: ErrorBody },
68
+ doc: { summary: 'Your social settings, notification preferences and push subscription count', tags: ['social'] },
69
+ },
70
+ }),
71
+ ...requireActive.group({
72
+ listFriendsAndRequests: {
73
+ method: 'GET', path: '/social/friends', response: FriendsList,
74
+ errors: { 401: ErrorBody, 403: ErrorBody },
75
+ doc: { summary: 'Friends (online first), requests both ways and blocks', tags: ['social'] },
76
+ },
77
+ findPlayerByName: {
78
+ method: 'GET', path: '/social/players/by-name/:name',
79
+ params: z.object({ name: z.string() }), response: PlayerCard,
80
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
81
+ doc: { summary: 'A player card, by username', tags: ['social'] },
82
+ },
83
+ getPlayerCard: {
84
+ method: 'GET', path: '/social/players/:id', params: OtherUser, response: PlayerCard,
85
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
86
+ doc: { summary: 'A player card, by id (404 `no_player` when blocked by them or suspended)', tags: ['social'] },
87
+ },
88
+ }),
89
+ ...requireClaimed.group({
90
+ updateMySocialProfile: {
91
+ method: 'PUT', path: '/social/me',
92
+ body: z.object({
93
+ /** One of the config's preset avatar ids (else 400 `bad_avatar`). */
94
+ avatar: z.string().optional(),
95
+ privacy: audienceField('privacy').optional(),
96
+ dmPolicy: audienceField('dmPolicy').optional(),
97
+ }),
98
+ response: SocialMeResult,
99
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
100
+ doc: { summary: 'Change your avatar, presence privacy or who may message you', tags: ['social'] },
101
+ },
102
+ setParentChatLock: {
103
+ method: 'POST', path: '/social/chat-lock',
104
+ body: z.union([
105
+ z.object({ on: z.literal(true) }),
106
+ z.object({
107
+ on: z.literal(false),
108
+ /** The account password; required to turn the lock off. */
109
+ password: z.string().optional(),
110
+ }),
111
+ ], { error: 'on must be true or false.' }),
112
+ response: SocialMeResult,
113
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 429: ErrorBody },
114
+ doc: {
115
+ summary: 'Parent chat lock: on with one tap, off only with the account password',
116
+ description: 'A wrong password is 403 `bad_password` (logged and rate-limited).',
117
+ tags: ['social'],
118
+ },
119
+ },
120
+ sendFriendRequest: {
121
+ method: 'POST', path: '/social/friends', body: FriendRequestInput,
122
+ response: z.object({ status: FriendRequestStatus }),
123
+ errors: {
124
+ 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody,
125
+ 422: ContentRejected, 429: ErrorBody,
126
+ },
127
+ doc: {
128
+ summary: 'Send a friend request (a reverse request accepts at once)',
129
+ description: 'Answers `requested` without doing anything when the target blocked you or is suspended. '
130
+ + '`message` is moderated now and delivered as the first DM once you are friends.',
131
+ tags: ['social'],
132
+ },
133
+ },
134
+ acceptFriendRequest: {
135
+ method: 'POST', path: '/social/friends/:id/accept', params: OtherUser, response: Ok,
136
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
137
+ doc: { summary: 'Accept the request from `:id`', tags: ['social'] },
138
+ },
139
+ declineFriendRequest: {
140
+ method: 'POST', path: '/social/friends/:id/decline', params: OtherUser, response: Ok,
141
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
142
+ doc: { summary: 'Quietly drop the request from `:id`', tags: ['social'] },
143
+ },
144
+ removeFriend: {
145
+ method: 'DELETE', path: '/social/friends/:id', params: OtherUser, response: Ok,
146
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
147
+ doc: { summary: 'Unfriend `:id`, or cancel your request to them', tags: ['social'] },
148
+ },
149
+ setFriendNotifications: {
150
+ method: 'PUT', path: '/social/friends/:id/notify', params: OtherUser,
151
+ body: z.object({ online: z.boolean({ error: 'online must be true or false.' }) }), response: Ok,
152
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
153
+ doc: { summary: "Turn a friend's \"tell me when they're online\" bell on or off", tags: ['social'] },
154
+ },
155
+ blockPlayer: {
156
+ method: 'POST', path: '/social/blocks', body: z.object({ userId: requiredId('userId must be a user id.') }), response: Ok,
157
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
158
+ doc: {
159
+ summary: 'Block a player',
160
+ description: 'Also unfriends, drops requests both ways and splits a shared party.',
161
+ tags: ['social'],
162
+ },
163
+ },
164
+ unblockPlayer: {
165
+ method: 'DELETE', path: '/social/blocks/:id', params: OtherUser, response: Ok,
166
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
167
+ doc: { summary: 'Unblock a player', tags: ['social'] },
168
+ },
169
+ listRecentPlayers: {
170
+ method: 'GET', path: '/social/recent',
171
+ response: z.object({
172
+ /** Newest first, at most 50. */
173
+ players: z.array(RecentPlayer),
174
+ }),
175
+ errors: { 401: ErrorBody, 403: ErrorBody },
176
+ doc: { summary: 'People you recently played with', tags: ['social'] },
177
+ },
178
+ }),
179
+ };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * People shapes: your own social settings, friends, requests, player cards
3
+ * and recent players. Several are also part of the socket `hello` snapshot.
4
+ *
5
+ * Privacy rules behind them: a block in either direction looks to the
6
+ * blocked side like an ordinary unfriend (or a missing player), suspended
7
+ * players are invisible, and guests have no friends.
8
+ */
9
+ import { z } from 'zod';
10
+ import { Millis, Slug } from '../../common';
11
+ import { PlayerLite, SessionUser } from '../../entities';
12
+ import { Presence } from './presence';
13
+
14
+ /** Who may see my presence, or message me: friends, or nobody. */
15
+ export const Audience = z.enum(['friends', 'nobody']);
16
+
17
+ /** An audience setting. */
18
+ export type Audience = z.infer<typeof Audience>;
19
+
20
+ /**
21
+ * Your own account's social fields (`GET`/`PUT /social/me`, the `hello`
22
+ * snapshot's `me`). Never shown to other players.
23
+ */
24
+ export const SocialMe = SessionUser.extend({
25
+ /** Avatar token; never empty (a default is derived from the id). */
26
+ avatar: PlayerLite.shape.avatar,
27
+ /** Who sees my presence and last-seen time. */
28
+ privacy: Audience,
29
+ /** Who may message me (and invite me). */
30
+ dmPolicy: Audience,
31
+ });
32
+
33
+ /** Your own social settings. */
34
+ export type SocialMe = z.infer<typeof SocialMe>;
35
+
36
+ /** A friend, with what you may see of their presence. Online friends sort first. */
37
+ export const FriendEntry = PlayerLite.extend({
38
+ /** `null` when offline or hiding their presence. */
39
+ presence: Presence.nullable(),
40
+ /** "Tell me when they come online" is on for this friend. */
41
+ notifyOnline: z.boolean(),
42
+ /** When you became friends (epoch ms). */
43
+ since: Millis,
44
+ /** When they were last online (epoch ms); `0` when unknown or they hide their presence. */
45
+ lastSeenAt: Millis,
46
+ });
47
+
48
+ /** A friend. */
49
+ export type FriendEntry = z.infer<typeof FriendEntry>;
50
+
51
+ /** Pending friend requests both ways, newest first (blocked and suspended players left out). */
52
+ export const FriendRequests = z.object({
53
+ /** Requests waiting for you. */
54
+ incoming: z.array(PlayerLite),
55
+ /** Requests you sent. */
56
+ outgoing: z.array(PlayerLite),
57
+ });
58
+
59
+ /** Pending friend requests. */
60
+ export type FriendRequests = z.infer<typeof FriendRequests>;
61
+
62
+ /** `GET /social/friends`: friends, requests and the players you blocked. Guests get empty lists. */
63
+ export const FriendsList = FriendRequests.extend({
64
+ friends: z.array(FriendEntry),
65
+ /** Players you blocked, newest first. */
66
+ blocked: z.array(PlayerLite),
67
+ });
68
+
69
+ /** Friends, requests and blocks. */
70
+ export type FriendsList = z.infer<typeof FriendsList>;
71
+
72
+ /**
73
+ * What changed in `ev:friends` (the client refetches `GET /social/friends`).
74
+ * `restored`: a friend's suspension ended. A player you blocked only ever
75
+ * sees `removed`.
76
+ */
77
+ export const FriendChange = z.enum(['request', 'accepted', 'removed', 'declined', 'cancelled', 'blocked', 'restored']);
78
+
79
+ /** A friends-list change. */
80
+ export type FriendChange = z.infer<typeof FriendChange>;
81
+
82
+ /** My relation to another player, as their card shows it. */
83
+ export const Relation = z.enum(['self', 'friend', 'incoming', 'outgoing', 'blocked', 'none']);
84
+
85
+ /** A relation. */
86
+ export type Relation = z.infer<typeof Relation>;
87
+
88
+ /**
89
+ * A player card (`GET /social/players/:id`). Someone who blocked you, or who
90
+ * is suspended, answers 404 exactly like a player who doesn't exist.
91
+ */
92
+ export const PlayerCard = PlayerLite.extend({
93
+ relation: Relation,
94
+ /** Friends only; `null` otherwise, or when offline or hidden. */
95
+ presence: Presence.nullable(),
96
+ /** A friend request can be sent (both claimed, no relation yet or theirs is pending, requests on). */
97
+ canFriend: z.boolean(),
98
+ /** Friends who take messages, while DMs are on. */
99
+ canMessage: z.boolean(),
100
+ /** Friends who take messages. */
101
+ canInvite: z.boolean(),
102
+ /** Anyone but yourself. */
103
+ canReport: z.boolean(),
104
+ });
105
+
106
+ /** A player card. */
107
+ export type PlayerCard = z.infer<typeof PlayerCard>;
108
+
109
+ /** Someone you recently played with (`GET /social/recent`). */
110
+ export const RecentPlayer = PlayerLite.extend({
111
+ /** The game you played together. */
112
+ game: Slug,
113
+ /** When you last played together (epoch ms). */
114
+ lastAt: Millis,
115
+ });
116
+
117
+ /** A recent player. */
118
+ export type RecentPlayer = z.infer<typeof RecentPlayer>;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Presence: what a player is doing right now, as their friends see it, and
3
+ * the anonymous online counts (multiplayer-impl.md §4.4). Games report a
4
+ * {@link PresenceInput} per tab (socket op `presence`); the hub folds a
5
+ * user's tabs into one {@link Presence} and pushes it to friends
6
+ * (`ev:presence`).
7
+ */
8
+ import { z } from 'zod';
9
+ import { Millis, Slug } from '../../common';
10
+
11
+ /** Activity kinds, least engaged first. The most engaged tab wins; hidden or idle tabs count as `away`. */
12
+ export const PresenceKind = z.enum(['away', 'menu', 'lobby', 'watching', 'playing']);
13
+
14
+ /** An activity kind. */
15
+ export type PresenceKind = z.infer<typeof PresenceKind>;
16
+
17
+ /** A room reference friends can join with: 1 to 64 of `A-Z a-z 0-9 _ : -`. */
18
+ export const PresenceRoom = z.string().regex(/^[A-Za-z0-9_:-]{1,64}$/);
19
+
20
+ /** A game mode key: 1 to 32 of `A-Z a-z 0-9 _ -`. */
21
+ export const PresenceMode = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/);
22
+
23
+ /** The fields a tab reports and friends see back, with the limits the hub enforces. */
24
+ const PresenceFields = z.object({
25
+ kind: PresenceKind,
26
+ /** A short status line (at most 40 characters), filtered; a rejected one is dropped. */
27
+ detail: z.string().max(40).optional(),
28
+ /** Friends may join. Only ever `true` when present. */
29
+ joinable: z.boolean().optional(),
30
+ /** Friends may watch. Only ever `true` when present. */
31
+ watchable: z.boolean().optional(),
32
+ /** Join info for friends: the game's own room reference, or the hub room's code. */
33
+ room: PresenceRoom.optional(),
34
+ /** A public lobby: friends who follow the game may get a `lobby_open` alert. */
35
+ public: z.boolean().optional(),
36
+ /** Free seats, 0 to 64. */
37
+ openSeats: z.number().int().min(0).max(64).optional(),
38
+ mode: PresenceMode.optional(),
39
+ });
40
+
41
+ /**
42
+ * What a game reports for its tab (socket op `presence`). The hub is
43
+ * lenient: an unknown `kind`, or input that isn't an object at all (`null`),
44
+ * counts as `{kind:'menu'}`; unknown fields, and fields that break a rule (a
45
+ * bad `room`, a fractional `openSeats`, a field of the wrong type), are
46
+ * ignored one by one, so friends only ever
47
+ * see {@link Presence}'s fields; `detail` is trimmed to 40 characters before
48
+ * filtering.
49
+ */
50
+ export const PresenceInput = z.looseObject({
51
+ kind: PresenceKind.catch('menu'),
52
+ detail: z.string().optional().catch(undefined),
53
+ joinable: z.boolean().optional().catch(undefined),
54
+ watchable: z.boolean().optional().catch(undefined),
55
+ room: z.string().optional().catch(undefined),
56
+ public: z.boolean().optional().catch(undefined),
57
+ openSeats: z.number().optional().catch(undefined),
58
+ mode: z.string().optional().catch(undefined),
59
+ }).catch({ kind: 'menu' });
60
+
61
+ /** A presence report. */
62
+ export type PresenceInput = z.input<typeof PresenceInput>;
63
+
64
+ /** A user's aggregated presence: their most engaged tab, decorated by the hub. */
65
+ export const Presence = PresenceFields.extend({
66
+ /** The game the tab is on, `null` on the catalog and other hub pages. */
67
+ game: Slug.nullable(),
68
+ /** That game's title, `null` with `game`. */
69
+ gameTitle: z.string().nullable(),
70
+ /** When this activity (kind + game) started (epoch ms). */
71
+ since: Millis,
72
+ /** Party size, present when the user is in a party of two or more. */
73
+ partySize: z.number().int().min(2).optional(),
74
+ });
75
+
76
+ /** A user's aggregated presence. */
77
+ export type Presence = z.infer<typeof Presence>;
78
+
79
+ /** A non-negative head count. */
80
+ const HeadCount = z.number().int().nonnegative();
81
+
82
+ /**
83
+ * Anonymous online counts (`GET /social/counts`, `ev:counts`, the `hello`
84
+ * snapshot): everyone with a live presence, guests and hidden players
85
+ * included.
86
+ */
87
+ export const Counts = z.object({
88
+ /** Everyone online. */
89
+ online: HeadCount,
90
+ /** Players per game slug (games with nobody are absent). */
91
+ byGame: z.record(Slug, HeadCount),
92
+ });
93
+
94
+ /** Online counts. */
95
+ export type Counts = z.infer<typeof Counts>;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Friends, blocks, the social profile and settings, player cards and counts,
3
+ * plus the shared social shapes (config, presence, people) the socket
4
+ * `hello` snapshot and events reuse. Split by topic under `social/`.
5
+ */
6
+ export * from './social/config';
7
+ export * from './social/presence';
8
+ export * from './social/people';
9
+ export * from './social/endpoints';
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `GET /version`: which client versions this hub serves (plan §4.3.3). Games'
3
+ * clients read it once per page, and `hub-doctor` reads it in a game's CI.
4
+ * The old hub has no such route and answers 404 "No such API endpoint.", which
5
+ * clients read as "a hub from before versioning".
6
+ */
7
+ import { z } from 'zod';
8
+ import { session } from '../middleware';
9
+
10
+ /**
11
+ * A semver range in the npm form: comparators (`>=2.0.0-0`, `<3.0.0`)
12
+ * separated by spaces are all required; `||` separates alternatives.
13
+ */
14
+ export const VersionRange = z.string().min(1);
15
+
16
+ /** What `GET /version` answers. */
17
+ export const HubVersion = z.object({
18
+ /** The hub release this server was built from: the version of the client it serves at `/_hub/hub.js`. */
19
+ server: z.string(),
20
+ /** The version of `@diffenderfer-games/hub-contract` the server implements. */
21
+ contract: z.string(),
22
+ /** Client versions the server supports (`x-hub-client`); a client outside it should be updated. */
23
+ supported: VersionRange,
24
+ /** Supported client versions that are deprecated: they still work, and `hub-doctor` warns. */
25
+ deprecated: VersionRange,
26
+ });
27
+
28
+ /** The hub's version and the client versions it supports. */
29
+ export type HubVersion = z.infer<typeof HubVersion>;
30
+
31
+ /** The version endpoints. */
32
+ export const versionEndpoints = {
33
+ ...session.group({
34
+ getHubVersion: {
35
+ method: 'GET', path: '/version',
36
+ response: HubVersion,
37
+ doc: { summary: 'The hub version and the client versions it supports', tags: ['catalog'] },
38
+ },
39
+ }),
40
+ };
package/src/coerce.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Request coercions the single-player handlers apply today. The old hub reads
3
+ * many request fields loosely (`Number(x)`, `String(x)`, truthiness), and the
4
+ * contract must accept every request the hub accepts, so these schemas
5
+ * coerce the same way while keeping the input type clients should send.
6
+ */
7
+ import { z } from 'zod';
8
+
9
+ /**
10
+ * A finite number, read like the hub's `Number(x)`: numeric strings (and
11
+ * query-string values) pass. Clients should send a JSON number.
12
+ */
13
+ export const LooseNumber = z.coerce.number<number>();
14
+
15
+ /**
16
+ * A flag read by truthiness, like the hub's `if (body.on)`: any value is
17
+ * accepted and becomes `true` or `false`. Clients should send a boolean.
18
+ */
19
+ export const Truthy = z.coerce.boolean<boolean>();
20
+
21
+ /**
22
+ * Free text read with `String(x)`; the hub trims or clips it to its own limit
23
+ * rather than rejecting it. Clients should send a string.
24
+ */
25
+ export const LooseText = z.coerce.string<string>();
26
+
27
+ /**
28
+ * A row count from the query string (`?limit=`, `?top=`). The hub clamps it to
29
+ * its own range and falls back to a default for `0` or a missing value.
30
+ */
31
+ export const RowCount = LooseNumber.int();
32
+
33
+ /**
34
+ * Required text read like the hub's `String(x || '')`, with the old hub's
35
+ * message for a missing or invalid value. Numbers are accepted and become
36
+ * strings; anything else fails with `message`. Refine the result with
37
+ * `.pipe(...)` (e.g. a pattern carrying the same message).
38
+ */
39
+ export function requiredText(message: string) {
40
+ return z.union([z.string(), z.number()], { error: message }).transform(String);
41
+ }
42
+
43
+ /**
44
+ * A positive integer id read like the hub's `Number(x)` (numeric strings
45
+ * pass), with the old hub's message for any bad value (`"id must be a user
46
+ * id."`). Clients should send a JSON number.
47
+ */
48
+ export function requiredId(message: string) {
49
+ return z.coerce.number<number>({ error: message }).int({ error: message }).positive({ error: message });
50
+ }
package/src/common.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Primitives shared by every part of the contract. Each one is the single
3
+ * definition of its rule: the server validates with it, the client's types
4
+ * come from it, and the docs are generated from it.
5
+ */
6
+ import { z } from 'zod';
7
+
8
+ /** A user id: a positive integer. In paths it arrives as a string and is coerced. */
9
+ export const UserId = z.number().int().positive();
10
+
11
+ /** A user id taken from a URL path segment (`/players/:userId`). */
12
+ export const UserIdParam = z.coerce.number().int().positive();
13
+
14
+ /** A numeric row id taken from a URL path segment (`/trades/:id`, `/reports/:id`). */
15
+ export const IdParam = z.coerce.number().int().positive();
16
+
17
+ /** Milliseconds since the Unix epoch. */
18
+ export const Millis = z.number().int().nonnegative();
19
+
20
+ /** A game's slug: its folder name under `apps/`, as used in `/<slug>/` URLs. */
21
+ export const Slug = z.string().min(1).max(64);
22
+
23
+ /**
24
+ * An app-chosen identifier: a save slot, leaderboard key or stat name.
25
+ * Letters, digits and `_ . : -`, 1 to 64 characters.
26
+ */
27
+ export const Key = z.string().regex(/^[A-Za-z0-9_.:-]{1,64}$/);
28
+
29
+ /** Any JSON value (saves, metadata, room state). */
30
+ export const Json = z.json();
31
+
32
+ /** A JSON value as stored and returned by the hub: never `undefined`. */
33
+ export type Json = z.infer<typeof Json>;
34
+
35
+ /** The `{ ok: true }` body many write endpoints answer with. */
36
+ export const Ok = z.object({ ok: z.literal(true) });
37
+
38
+ /** A UTC day key, `YYYY-MM-DD`. */
39
+ export const DayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
40
+
41
+ /** A `#rrggbb` colour. */
42
+ export const HexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Entity shapes that several areas of the API share. Shapes used by only one
3
+ * area live in that area's `api/` file.
4
+ */
5
+ import { z } from 'zod';
6
+ import { Millis, UserId } from './common';
7
+
8
+ /** Account roles. An empty string is a normal player. */
9
+ export const Role = z.enum(['', 'admin']);
10
+
11
+ /**
12
+ * The signed-in user as the hub sees it (`GET /me`, socket `hello`).
13
+ * `id`, `username` and `guest` are the long-standing fields; the rest drive
14
+ * social permissions and are only ever sent to the user themself.
15
+ */
16
+ export const SessionUser = z.object({
17
+ id: UserId,
18
+ username: z.string(),
19
+ /** A temporary account with no password yet; it can be claimed. */
20
+ guest: z.boolean(),
21
+ role: Role,
22
+ /** Avatar token, `''` for the default. */
23
+ avatar: z.string(),
24
+ /** A parent locked chat for this account. */
25
+ chatLock: z.boolean(),
26
+ /** Muted until this time (epoch ms), `0` when not muted. */
27
+ mutedUntil: Millis,
28
+ /** Suspended until this time (epoch ms), `0` when not suspended. */
29
+ suspendedUntil: Millis,
30
+ });
31
+
32
+ /** The signed-in user. */
33
+ export type SessionUser = z.infer<typeof SessionUser>;
34
+
35
+ /** The short account shape the auth endpoints return for a new session. */
36
+ export const AuthUser = SessionUser.pick({ id: true, username: true, guest: true });
37
+
38
+ /** The short account shape. */
39
+ export type AuthUser = z.infer<typeof AuthUser>;
40
+
41
+ /**
42
+ * The effective chat mode of a conversation, room or the whole site: free
43
+ * text (still filtered), quick-chat phrases only, or no messaging at all.
44
+ * The strictest of the operator setting, the admin switch and every
45
+ * participant (guests and parent-locked accounts are `quick`) applies.
46
+ */
47
+ export const ChatMode = z.enum(['on', 'quick', 'off']);
48
+
49
+ /** A chat mode. */
50
+ export type ChatMode = z.infer<typeof ChatMode>;
51
+
52
+ /** Another player as lists and cards show them. */
53
+ export const PlayerLite = z.object({
54
+ id: UserId,
55
+ username: z.string(),
56
+ /** Avatar token; never empty (a default is derived from the id). */
57
+ avatar: z.string(),
58
+ guest: z.boolean(),
59
+ });
60
+
61
+ /** Another player. */
62
+ export type PlayerLite = z.infer<typeof PlayerLite>;
@@ -0,0 +1,10 @@
1
+ /** Error codes any endpoint or socket op can return. */
2
+ export const CORE_CODES = [
3
+ 'bad_request',
4
+ 'signed_out',
5
+ 'suspended',
6
+ 'claim_required',
7
+ 'rate_limited',
8
+ 'content_rejected',
9
+ 'too_large',
10
+ ] as const;
@@ -0,0 +1,43 @@
1
+ /** Error codes from invites, parties, hub rooms, races and the socket envelope. */
2
+ export const MULTIPLAYER_CODES = [
3
+ // Socket envelope.
4
+ 'unknown_op',
5
+ 'hello_required',
6
+ // Games and modes.
7
+ 'not_multiplayer',
8
+ 'not_supported',
9
+ 'bad_mode',
10
+ // Invites, parties, launches, joins.
11
+ 'bad_target',
12
+ 'cannot_invite',
13
+ 'already_member',
14
+ 'party_full',
15
+ 'invite_gone',
16
+ 'party_gone',
17
+ 'not_leader',
18
+ 'bad_info',
19
+ 'launch_invalid',
20
+ 'not_joinable',
21
+ // Group chat.
22
+ 'bad_quick',
23
+ // Hub rooms.
24
+ 'room_not_found',
25
+ 'not_in_room',
26
+ 'not_host',
27
+ 'too_many_rooms',
28
+ 'banned',
29
+ 'in_progress',
30
+ 'room_full',
31
+ 'not_ready',
32
+ 'ended',
33
+ 'bad_patch',
34
+ 'race_room',
35
+ // Races.
36
+ 'not_race',
37
+ 'not_in_race',
38
+ 'not_started',
39
+ 'race_over',
40
+ 'too_fast',
41
+ 'bad_stats',
42
+ 'bad_param',
43
+ ] as const;
@@ -0,0 +1,5 @@
1
+ /** Error codes from accounts and the single-player API (saves, stats, boards, daily, economy, trades). */
2
+ export const PLAY_CODES = [
3
+ /** A score was sent to a board only the game's own server may update (`source: "server"`). */
4
+ 'server_board',
5
+ ] as const;