@diffenderfer-games/hub-contract 2.0.0-next.0 → 2.0.0-next.1

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 (68) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -21
  3. package/README.md +26 -26
  4. package/dist/index.d.ts +95 -95
  5. package/package.json +53 -53
  6. package/src/api/analytics.ts +81 -81
  7. package/src/api/auth.ts +77 -77
  8. package/src/api/catalog.ts +171 -171
  9. package/src/api/daily.ts +122 -122
  10. package/src/api/economy/bundle.ts +69 -69
  11. package/src/api/index.ts +85 -85
  12. package/src/api/leaderboards.ts +151 -151
  13. package/src/api/messages.ts +142 -142
  14. package/src/api/mp/chat.ts +36 -36
  15. package/src/api/mp/races.ts +211 -211
  16. package/src/api/mp/rooms.ts +90 -90
  17. package/src/api/mp/snapshot.ts +17 -17
  18. package/src/api/mp.ts +110 -110
  19. package/src/api/notify.ts +111 -111
  20. package/src/api/progress/game-data.ts +189 -189
  21. package/src/api/progress.ts +59 -59
  22. package/src/api/race.ts +47 -47
  23. package/src/api/reports.ts +208 -208
  24. package/src/api/social/config.ts +47 -47
  25. package/src/api/social/endpoints.ts +179 -179
  26. package/src/api/social/people.ts +118 -118
  27. package/src/api/social/presence.ts +95 -95
  28. package/src/api/social.ts +9 -9
  29. package/src/api/version.ts +40 -40
  30. package/src/coerce.ts +50 -50
  31. package/src/common.ts +42 -42
  32. package/src/entities.ts +62 -62
  33. package/src/errors/codes/core.ts +10 -10
  34. package/src/errors/codes/multiplayer.ts +43 -43
  35. package/src/errors/codes/play.ts +5 -5
  36. package/src/errors/codes/social.ts +31 -31
  37. package/src/errors/index.ts +88 -88
  38. package/src/index.ts +16 -16
  39. package/src/limits.ts +100 -100
  40. package/src/logic/daily.ts +54 -54
  41. package/src/logic/index.ts +8 -8
  42. package/src/logic/presence.ts +62 -62
  43. package/src/logic/time.ts +81 -81
  44. package/src/manifest/changes.ts +55 -55
  45. package/src/manifest/controls.ts +42 -42
  46. package/src/manifest/fields.ts +89 -89
  47. package/src/manifest/game.ts +132 -132
  48. package/src/manifest/index.ts +12 -12
  49. package/src/manifest/inject.ts +31 -31
  50. package/src/manifest/leaderboards.ts +29 -29
  51. package/src/manifest/multiplayer.ts +71 -71
  52. package/src/manifest/race.ts +93 -93
  53. package/src/middleware.ts +47 -47
  54. package/src/realtime/define.ts +52 -52
  55. package/src/realtime/envelope.ts +67 -67
  56. package/src/realtime/events/multiplayer.ts +8 -8
  57. package/src/realtime/events/parties.ts +31 -31
  58. package/src/realtime/events/races.ts +27 -27
  59. package/src/realtime/events/rooms.ts +48 -48
  60. package/src/realtime/events/social.ts +81 -81
  61. package/src/realtime/index.ts +24 -24
  62. package/src/realtime/ops/empty.ts +8 -8
  63. package/src/realtime/ops/hello.ts +40 -40
  64. package/src/realtime/ops/multiplayer.ts +8 -8
  65. package/src/realtime/ops/parties.ts +13 -13
  66. package/src/realtime/ops/races.ts +99 -99
  67. package/src/realtime/ops/rooms.ts +1 -1
  68. package/src/realtime/ops/social.ts +58 -58
package/src/api/mp.ts CHANGED
@@ -1,110 +1,110 @@
1
- /**
2
- * Multiplayer REST (multiplayer-impl.md §5.6): invites, launch tokens,
3
- * parties, joining a friend's game, and game-server tickets. Every route
4
- * needs an active account; invites need a claimed one. The live state is in
5
- * the hub's memory and changes are pushed over `/_ws` (`invite`,
6
- * `invite.update`, `party`).
7
- */
8
- import { z } from 'zod';
9
- import { Millis, Ok, Slug, UserIdParam } from '../common';
10
- import { ErrorBody } from '../errors';
11
- import { requireActive, requireClaimed } from '../middleware';
12
- import { GameMode, Invite, JoinInfo, Launch, LaunchTicket, OpaqueId } from './mp/parties';
13
-
14
- export * from './mp/chat';
15
- export * from './mp/parties';
16
- export * from './mp/races';
17
- export * from './mp/rooms';
18
- export * from './mp/snapshot';
19
-
20
- /** `:id` of an invite or party. */
21
- const IdParams = z.object({ id: OpaqueId });
22
-
23
- /** The answer to `POST /mp/invites`: the invite, plus the inviter's own `host` launch. */
24
- export const InviteCreated = LaunchTicket.extend({ invite: Invite });
25
-
26
- /** A game-server identity ticket. */
27
- export const GameTicket = z.object({
28
- /** `base64url(payload).base64url(hmac)`, verified offline by the game's server with `HUB_APP_KEY`. */
29
- ticket: z.string(),
30
- /** Epoch ms; tickets live 60 s. */
31
- expiresAt: Millis,
32
- });
33
-
34
- /** The mp endpoints. */
35
- export const mpEndpoints = {
36
- ...requireClaimed.group({
37
- sendGameInvite: {
38
- method: 'POST', path: '/mp/invites',
39
- body: z.object({
40
- /** The friend to invite. */
41
- to: UserIdParam,
42
- game: Slug,
43
- /** One of the game's modes; race-only games default to `race`. */
44
- mode: GameMode.nullish(),
45
- }),
46
- response: InviteCreated,
47
- errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 429: ErrorBody },
48
- doc: {
49
- summary: 'Invite a friend to a game; creates or reuses your party and returns your host launch',
50
- tags: ['mp'],
51
- },
52
- },
53
- acceptGameInvite: {
54
- method: 'POST', path: '/mp/invites/:id/accept', params: IdParams, response: LaunchTicket,
55
- errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
56
- doc: { summary: "Accept an invite: join the inviter's party and get a guest launch", tags: ['mp'] },
57
- },
58
- declineGameInvite: {
59
- method: 'POST', path: '/mp/invites/:id/decline', params: IdParams, response: Ok,
60
- errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
61
- doc: { summary: 'Decline an invite addressed to you', tags: ['mp'] },
62
- },
63
- cancelGameInvite: {
64
- method: 'DELETE', path: '/mp/invites/:id', params: IdParams, response: Ok,
65
- errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
66
- doc: { summary: 'Cancel an invite you sent', tags: ['mp'] },
67
- },
68
- }),
69
- ...requireActive.group({
70
- redeemLaunchToken: {
71
- method: 'GET', path: '/mp/launch/:token', params: z.object({ token: z.string() }),
72
- response: z.object({ launch: Launch }),
73
- errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
74
- doc: { summary: 'Redeem a launch token (single use, bound to the signed-in user)', tags: ['mp'] },
75
- },
76
- setPartyJoinInfo: {
77
- method: 'POST', path: '/mp/parties/:id/join-info', params: IdParams,
78
- body: z.object({ info: JoinInfo }),
79
- response: Ok,
80
- errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
81
- doc: { summary: 'Party leader: publish where the party should connect', tags: ['mp'] },
82
- },
83
- leaveParty: {
84
- method: 'POST', path: '/mp/parties/:id/leave', params: IdParams, response: Ok,
85
- errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
86
- doc: { summary: 'Leave a party (the leader role passes on; an empty party dissolves)', tags: ['mp'] },
87
- },
88
- launchIntoFriendsRoom: {
89
- method: 'POST', path: '/mp/join',
90
- body: z.object({
91
- /** The player whose game to join or watch. */
92
- userId: UserIdParam,
93
- /** Watch instead of play. */
94
- watch: z.boolean().optional(),
95
- /** Joining a stranger's public lobby from a `lobby_open` alert (not a friend's game). */
96
- fromLobbyOpen: z.boolean().optional(),
97
- }),
98
- response: LaunchTicket,
99
- errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 429: ErrorBody },
100
- doc: { summary: "A Join or Watch launch into the room a friend's presence advertises", tags: ['mp'] },
101
- },
102
- getGameServerTicket: {
103
- method: 'POST', path: '/mp/ticket',
104
- body: z.object({ slug: Slug }),
105
- response: GameTicket,
106
- errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 429: ErrorBody },
107
- doc: { summary: "A 60 s identity ticket for a game's own server", tags: ['mp'] },
108
- },
109
- }),
110
- };
1
+ /**
2
+ * Multiplayer REST (multiplayer-impl.md §5.6): invites, launch tokens,
3
+ * parties, joining a friend's game, and game-server tickets. Every route
4
+ * needs an active account; invites need a claimed one. The live state is in
5
+ * the hub's memory and changes are pushed over `/_ws` (`invite`,
6
+ * `invite.update`, `party`).
7
+ */
8
+ import { z } from 'zod';
9
+ import { Millis, Ok, Slug, UserIdParam } from '../common';
10
+ import { ErrorBody } from '../errors';
11
+ import { requireActive, requireClaimed } from '../middleware';
12
+ import { GameMode, Invite, JoinInfo, Launch, LaunchTicket, OpaqueId } from './mp/parties';
13
+
14
+ export * from './mp/chat';
15
+ export * from './mp/parties';
16
+ export * from './mp/races';
17
+ export * from './mp/rooms';
18
+ export * from './mp/snapshot';
19
+
20
+ /** `:id` of an invite or party. */
21
+ const IdParams = z.object({ id: OpaqueId });
22
+
23
+ /** The answer to `POST /mp/invites`: the invite, plus the inviter's own `host` launch. */
24
+ export const InviteCreated = LaunchTicket.extend({ invite: Invite });
25
+
26
+ /** A game-server identity ticket. */
27
+ export const GameTicket = z.object({
28
+ /** `base64url(payload).base64url(hmac)`, verified offline by the game's server with `HUB_APP_KEY`. */
29
+ ticket: z.string(),
30
+ /** Epoch ms; tickets live 60 s. */
31
+ expiresAt: Millis,
32
+ });
33
+
34
+ /** The mp endpoints. */
35
+ export const mpEndpoints = {
36
+ ...requireClaimed.group({
37
+ sendGameInvite: {
38
+ method: 'POST', path: '/mp/invites',
39
+ body: z.object({
40
+ /** The friend to invite. */
41
+ to: UserIdParam,
42
+ game: Slug,
43
+ /** One of the game's modes; race-only games default to `race`. */
44
+ mode: GameMode.nullish(),
45
+ }),
46
+ response: InviteCreated,
47
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 429: ErrorBody },
48
+ doc: {
49
+ summary: 'Invite a friend to a game; creates or reuses your party and returns your host launch',
50
+ tags: ['mp'],
51
+ },
52
+ },
53
+ acceptGameInvite: {
54
+ method: 'POST', path: '/mp/invites/:id/accept', params: IdParams, response: LaunchTicket,
55
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
56
+ doc: { summary: "Accept an invite: join the inviter's party and get a guest launch", tags: ['mp'] },
57
+ },
58
+ declineGameInvite: {
59
+ method: 'POST', path: '/mp/invites/:id/decline', params: IdParams, response: Ok,
60
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
61
+ doc: { summary: 'Decline an invite addressed to you', tags: ['mp'] },
62
+ },
63
+ cancelGameInvite: {
64
+ method: 'DELETE', path: '/mp/invites/:id', params: IdParams, response: Ok,
65
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
66
+ doc: { summary: 'Cancel an invite you sent', tags: ['mp'] },
67
+ },
68
+ }),
69
+ ...requireActive.group({
70
+ redeemLaunchToken: {
71
+ method: 'GET', path: '/mp/launch/:token', params: z.object({ token: z.string() }),
72
+ response: z.object({ launch: Launch }),
73
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
74
+ doc: { summary: 'Redeem a launch token (single use, bound to the signed-in user)', tags: ['mp'] },
75
+ },
76
+ setPartyJoinInfo: {
77
+ method: 'POST', path: '/mp/parties/:id/join-info', params: IdParams,
78
+ body: z.object({ info: JoinInfo }),
79
+ response: Ok,
80
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
81
+ doc: { summary: 'Party leader: publish where the party should connect', tags: ['mp'] },
82
+ },
83
+ leaveParty: {
84
+ method: 'POST', path: '/mp/parties/:id/leave', params: IdParams, response: Ok,
85
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
86
+ doc: { summary: 'Leave a party (the leader role passes on; an empty party dissolves)', tags: ['mp'] },
87
+ },
88
+ launchIntoFriendsRoom: {
89
+ method: 'POST', path: '/mp/join',
90
+ body: z.object({
91
+ /** The player whose game to join or watch. */
92
+ userId: UserIdParam,
93
+ /** Watch instead of play. */
94
+ watch: z.boolean().optional(),
95
+ /** Joining a stranger's public lobby from a `lobby_open` alert (not a friend's game). */
96
+ fromLobbyOpen: z.boolean().optional(),
97
+ }),
98
+ response: LaunchTicket,
99
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 429: ErrorBody },
100
+ doc: { summary: "A Join or Watch launch into the room a friend's presence advertises", tags: ['mp'] },
101
+ },
102
+ getGameServerTicket: {
103
+ method: 'POST', path: '/mp/ticket',
104
+ body: z.object({ slug: Slug }),
105
+ response: GameTicket,
106
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 429: ErrorBody },
107
+ doc: { summary: "A 60 s identity ticket for a game's own server", tags: ['mp'] },
108
+ },
109
+ }),
110
+ };
package/src/api/notify.ts CHANGED
@@ -1,111 +1,111 @@
1
- /**
2
- * Notification preferences and Web Push subscriptions
3
- * (multiplayer-impl.md §5.4). Everything is off by default and claimed-only.
4
- * Which channel an alert takes (a live `ev:notice` toast or a push) is the
5
- * hub's decision; see `ev:notice`.
6
- */
7
- import { z } from 'zod';
8
- import { Ok, Slug, UserId } from '../common';
9
- import { ErrorBody } from '../errors';
10
- import { requireActive, requireClaimed } from '../middleware';
11
-
12
- /** Notification preferences (`GET`/`PUT /social/notify`, `GET /social/me`). */
13
- export const NotifyPrefs = z.object({
14
- /** Push a new DM when no tab is open. */
15
- dm: z.boolean(),
16
- /** Push a friend request when no tab is open. */
17
- friend_request: z.boolean(),
18
- /** "Tell me when any friend comes online", on top of the per-friend bells. */
19
- friendOnlineAll: z.boolean(),
20
- /** Friends whose bell is on, ascending. */
21
- friendOnline: z.array(UserId),
22
- /** Games followed for "someone's waiting in a lobby", sorted. */
23
- lobbyOpen: z.array(Slug),
24
- });
25
-
26
- /** Notification preferences. */
27
- export type NotifyPrefs = z.infer<typeof NotifyPrefs>;
28
-
29
- /** The preference kinds `PUT /social/notify` can flip. */
30
- export const NotifyPrefKind = z.enum(['dm', 'friend_request', 'friend_online', 'lobby_open'], {
31
- error: 'kind must be dm, friend_request, friend_online or lobby_open.',
32
- });
33
-
34
- /** A preference kind. */
35
- export type NotifyPrefKind = z.infer<typeof NotifyPrefKind>;
36
-
37
- /**
38
- * One `PUT /social/notify` change. `target`: for `friend_online`, a friend's
39
- * id (a number, or its decimal string) for that friend's bell, or absent
40
- * (`''`, `null`) for the "any friend" switch; for `lobby_open`, the game slug.
41
- * Ignored for `dm` and `friend_request`. Any other number or text is a 404
42
- * (not a friend, no such game), as on the old hub. `enabled` is checked
43
- * first, so its message wins when both fields are wrong.
44
- */
45
- export const NotifyChange = z.object({
46
- enabled: z.boolean({ error: 'enabled must be true or false.' }),
47
- kind: NotifyPrefKind,
48
- target: z.union([z.string(), z.number()]).nullable().optional(),
49
- });
50
-
51
- /** A notification preference change. */
52
- export type NotifyChange = z.infer<typeof NotifyChange>;
53
-
54
- /** The 400 for a subscription the hub can't push to (malformed, or not on a known push service). */
55
- export const UNSUPPORTED_SUBSCRIPTION = "That browser subscription isn't supported.";
56
-
57
- /**
58
- * A browser push subscription, as `PushSubscription.toJSON()` gives it. The
59
- * endpoint must be `https` on a known push service; `p256dh` is a 65-byte
60
- * and `auth` an at-least-16-byte base64url key.
61
- */
62
- export const PushSubscriptionJson = z.object({
63
- endpoint: z.string().max(1000, { error: UNSUPPORTED_SUBSCRIPTION }),
64
- /** When the subscription expires (epoch ms), `null` when it doesn't. */
65
- expirationTime: z.number().nullable().optional(),
66
- keys: z.object({
67
- p256dh: z.string().max(200, { error: UNSUPPORTED_SUBSCRIPTION }),
68
- auth: z.string().max(100, { error: UNSUPPORTED_SUBSCRIPTION }),
69
- }),
70
- });
71
-
72
- /** A browser push subscription. */
73
- export type PushSubscriptionJson = z.infer<typeof PushSubscriptionJson>;
74
-
75
- /** The notification endpoints. */
76
- export const notifyEndpoints = {
77
- ...requireActive.group({
78
- getNotificationPreferences: {
79
- method: 'GET', path: '/social/notify', response: NotifyPrefs,
80
- errors: { 401: ErrorBody, 403: ErrorBody },
81
- doc: { summary: 'Your notification preferences (all off for guests)', tags: ['notify'] },
82
- },
83
- unsubscribeFromPush: {
84
- method: 'DELETE', path: '/social/push',
85
- body: z.object({ endpoint: z.string({ error: 'endpoint is required.' }).min(1, { error: 'endpoint is required.' }) }), response: Ok,
86
- errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
87
- doc: { summary: "Forget one of this account's push subscriptions", tags: ['notify'] },
88
- },
89
- }),
90
- ...requireClaimed.group({
91
- setNotificationPreferences: {
92
- method: 'PUT', path: '/social/notify', body: NotifyChange, response: NotifyPrefs,
93
- errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
94
- doc: {
95
- summary: 'Flip one notification preference',
96
- description: '404 for a friend bell on a non-friend or an unknown game; 409 past 50 followed games.',
97
- tags: ['notify'],
98
- },
99
- },
100
- subscribeToPush: {
101
- method: 'POST', path: '/social/push',
102
- body: z.object({ subscription: z.union([PushSubscriptionJson, z.never()], { error: UNSUPPORTED_SUBSCRIPTION }) }), response: Ok,
103
- errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
104
- doc: {
105
- summary: "Store this browser's push subscription",
106
- description: 'One browser is one endpoint: subscribing from another account moves it. Past the per-user cap the oldest is dropped.',
107
- tags: ['notify'],
108
- },
109
- },
110
- }),
111
- };
1
+ /**
2
+ * Notification preferences and Web Push subscriptions
3
+ * (multiplayer-impl.md §5.4). Everything is off by default and claimed-only.
4
+ * Which channel an alert takes (a live `ev:notice` toast or a push) is the
5
+ * hub's decision; see `ev:notice`.
6
+ */
7
+ import { z } from 'zod';
8
+ import { Ok, Slug, UserId } from '../common';
9
+ import { ErrorBody } from '../errors';
10
+ import { requireActive, requireClaimed } from '../middleware';
11
+
12
+ /** Notification preferences (`GET`/`PUT /social/notify`, `GET /social/me`). */
13
+ export const NotifyPrefs = z.object({
14
+ /** Push a new DM when no tab is open. */
15
+ dm: z.boolean(),
16
+ /** Push a friend request when no tab is open. */
17
+ friend_request: z.boolean(),
18
+ /** "Tell me when any friend comes online", on top of the per-friend bells. */
19
+ friendOnlineAll: z.boolean(),
20
+ /** Friends whose bell is on, ascending. */
21
+ friendOnline: z.array(UserId),
22
+ /** Games followed for "someone's waiting in a lobby", sorted. */
23
+ lobbyOpen: z.array(Slug),
24
+ });
25
+
26
+ /** Notification preferences. */
27
+ export type NotifyPrefs = z.infer<typeof NotifyPrefs>;
28
+
29
+ /** The preference kinds `PUT /social/notify` can flip. */
30
+ export const NotifyPrefKind = z.enum(['dm', 'friend_request', 'friend_online', 'lobby_open'], {
31
+ error: 'kind must be dm, friend_request, friend_online or lobby_open.',
32
+ });
33
+
34
+ /** A preference kind. */
35
+ export type NotifyPrefKind = z.infer<typeof NotifyPrefKind>;
36
+
37
+ /**
38
+ * One `PUT /social/notify` change. `target`: for `friend_online`, a friend's
39
+ * id (a number, or its decimal string) for that friend's bell, or absent
40
+ * (`''`, `null`) for the "any friend" switch; for `lobby_open`, the game slug.
41
+ * Ignored for `dm` and `friend_request`. Any other number or text is a 404
42
+ * (not a friend, no such game), as on the old hub. `enabled` is checked
43
+ * first, so its message wins when both fields are wrong.
44
+ */
45
+ export const NotifyChange = z.object({
46
+ enabled: z.boolean({ error: 'enabled must be true or false.' }),
47
+ kind: NotifyPrefKind,
48
+ target: z.union([z.string(), z.number()]).nullable().optional(),
49
+ });
50
+
51
+ /** A notification preference change. */
52
+ export type NotifyChange = z.infer<typeof NotifyChange>;
53
+
54
+ /** The 400 for a subscription the hub can't push to (malformed, or not on a known push service). */
55
+ export const UNSUPPORTED_SUBSCRIPTION = "That browser subscription isn't supported.";
56
+
57
+ /**
58
+ * A browser push subscription, as `PushSubscription.toJSON()` gives it. The
59
+ * endpoint must be `https` on a known push service; `p256dh` is a 65-byte
60
+ * and `auth` an at-least-16-byte base64url key.
61
+ */
62
+ export const PushSubscriptionJson = z.object({
63
+ endpoint: z.string().max(1000, { error: UNSUPPORTED_SUBSCRIPTION }),
64
+ /** When the subscription expires (epoch ms), `null` when it doesn't. */
65
+ expirationTime: z.number().nullable().optional(),
66
+ keys: z.object({
67
+ p256dh: z.string().max(200, { error: UNSUPPORTED_SUBSCRIPTION }),
68
+ auth: z.string().max(100, { error: UNSUPPORTED_SUBSCRIPTION }),
69
+ }),
70
+ });
71
+
72
+ /** A browser push subscription. */
73
+ export type PushSubscriptionJson = z.infer<typeof PushSubscriptionJson>;
74
+
75
+ /** The notification endpoints. */
76
+ export const notifyEndpoints = {
77
+ ...requireActive.group({
78
+ getNotificationPreferences: {
79
+ method: 'GET', path: '/social/notify', response: NotifyPrefs,
80
+ errors: { 401: ErrorBody, 403: ErrorBody },
81
+ doc: { summary: 'Your notification preferences (all off for guests)', tags: ['notify'] },
82
+ },
83
+ unsubscribeFromPush: {
84
+ method: 'DELETE', path: '/social/push',
85
+ body: z.object({ endpoint: z.string({ error: 'endpoint is required.' }).min(1, { error: 'endpoint is required.' }) }), response: Ok,
86
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
87
+ doc: { summary: "Forget one of this account's push subscriptions", tags: ['notify'] },
88
+ },
89
+ }),
90
+ ...requireClaimed.group({
91
+ setNotificationPreferences: {
92
+ method: 'PUT', path: '/social/notify', body: NotifyChange, response: NotifyPrefs,
93
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
94
+ doc: {
95
+ summary: 'Flip one notification preference',
96
+ description: '404 for a friend bell on a non-friend or an unknown game; 409 past 50 followed games.',
97
+ tags: ['notify'],
98
+ },
99
+ },
100
+ subscribeToPush: {
101
+ method: 'POST', path: '/social/push',
102
+ body: z.object({ subscription: z.union([PushSubscriptionJson, z.never()], { error: UNSUPPORTED_SUBSCRIPTION }) }), response: Ok,
103
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
104
+ doc: {
105
+ summary: "Store this browser's push subscription",
106
+ description: 'One browser is one endpoint: subscribing from another account moves it. Past the per-user cap the oldest is dropped.',
107
+ tags: ['notify'],
108
+ },
109
+ },
110
+ }),
111
+ };