@moneypot/hub 1.15.0-dev.3 → 1.16.0-dev.3

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 (36) hide show
  1. package/dist/src/__generated__/gql.d.ts +2 -2
  2. package/dist/src/__generated__/gql.js +1 -1
  3. package/dist/src/__generated__/graphql.d.ts +240 -27
  4. package/dist/src/__generated__/graphql.js +30 -1
  5. package/dist/src/db/index.d.ts +1 -0
  6. package/dist/src/db/index.js +10 -2
  7. package/dist/src/db/types.d.ts +24 -0
  8. package/dist/src/express.d.ts +1 -0
  9. package/dist/src/graphql-queries.js +4 -0
  10. package/dist/src/index.d.ts +2 -1
  11. package/dist/src/index.js +2 -1
  12. package/dist/src/pg-advisory-lock.d.ts +5 -0
  13. package/dist/src/pg-advisory-lock.js +5 -0
  14. package/dist/src/pg-versions/013-chat.sql +221 -0
  15. package/dist/src/plugins/chat/hub-chat-after-id-condition.d.ts +1 -0
  16. package/dist/src/plugins/chat/hub-chat-after-id-condition.js +15 -0
  17. package/dist/src/plugins/chat/hub-chat-create-system-message.d.ts +1 -0
  18. package/dist/src/plugins/chat/hub-chat-create-system-message.js +124 -0
  19. package/dist/src/plugins/chat/hub-chat-create-user-message.d.ts +1 -0
  20. package/dist/src/plugins/chat/hub-chat-create-user-message.js +231 -0
  21. package/dist/src/plugins/chat/hub-chat-mute-user.d.ts +1 -0
  22. package/dist/src/plugins/chat/hub-chat-mute-user.js +186 -0
  23. package/dist/src/plugins/chat/hub-chat-subscription.d.ts +14 -0
  24. package/dist/src/plugins/chat/hub-chat-subscription.js +133 -0
  25. package/dist/src/plugins/chat/hub-chat-unmute-user.d.ts +1 -0
  26. package/dist/src/plugins/chat/hub-chat-unmute-user.js +146 -0
  27. package/dist/src/plugins/hub-authenticate.js +40 -18
  28. package/dist/src/plugins/hub-create-playground-session.js +44 -13
  29. package/dist/src/server/graphile.config.d.ts +13 -1
  30. package/dist/src/server/graphile.config.js +38 -12
  31. package/dist/src/server/index.d.ts +3 -1
  32. package/dist/src/server/index.js +3 -1
  33. package/dist/src/server/middleware/authentication.js +1 -0
  34. package/dist/src/util.d.ts +3 -0
  35. package/dist/src/util.js +9 -0
  36. package/package.json +1 -1
@@ -0,0 +1,231 @@
1
+ import { GraphQLError } from "graphql";
2
+ import { access, context, object, ObjectStep, sideEffect, } from "postgraphile/grafast";
3
+ import { extendSchema, gql } from "postgraphile/utils";
4
+ import z from "zod/v4";
5
+ import { exactlyOneRow, maybeOneRow } from "../../db/util.js";
6
+ import { extractFirstZodErrorMessage } from "../../util.js";
7
+ import { withPgPoolTransaction } from "../../db/index.js";
8
+ import { PgAdvisoryLock } from "../../pg-advisory-lock.js";
9
+ const InputSchema = z.object({
10
+ clientId: z.uuid("Invalid client ID"),
11
+ body: z
12
+ .string()
13
+ .transform(normalizeUserMessageBody)
14
+ .refine((body) => body.length > 0, "Body is required")
15
+ .refine((body) => body.length <= 140, `Max body length: 140 chars`),
16
+ });
17
+ const RATE_LIMITS = [
18
+ {
19
+ seconds: 10,
20
+ cap: 3,
21
+ },
22
+ { seconds: 60, cap: 20 },
23
+ ];
24
+ const RL_WINDOWS = RATE_LIMITS.map((r) => r.seconds);
25
+ const RL_CAPS = RATE_LIMITS.map((r) => r.cap);
26
+ export const HubChatCreateUserMessagePlugin = extendSchema((build) => {
27
+ const chatMessageTable = build.input.pgRegistry.pgResources.hub_chat_message;
28
+ return {
29
+ typeDefs: gql `
30
+ input HubChatCreateUserMessageInput {
31
+ clientId: UUID!
32
+ body: String!
33
+ }
34
+
35
+ type HubChatCreateUserMessageSuccess {
36
+ chatMessage: HubChatMessage!
37
+ }
38
+
39
+ type HubChatUserRateLimited {
40
+ message: String
41
+ }
42
+
43
+ type HubChatUserMuted {
44
+ message: String
45
+ }
46
+
47
+ union HubChatCreateUserMessageResult =
48
+ HubChatCreateUserMessageSuccess
49
+ | HubChatUserRateLimited
50
+ | HubChatUserMuted
51
+
52
+ type HubChatCreateUserMessagePayload {
53
+ result: HubChatCreateUserMessageResult!
54
+ }
55
+
56
+ extend type Mutation {
57
+ hubChatCreateUserMessage(
58
+ input: HubChatCreateUserMessageInput!
59
+ ): HubChatCreateUserMessagePayload
60
+ }
61
+ `,
62
+ objects: {
63
+ Mutation: {
64
+ plans: {
65
+ hubChatCreateUserMessage(_, { $input }) {
66
+ const $identity = context().get("identity");
67
+ const $superuserPool = context().get("superuserPool");
68
+ const $payload = sideEffect([$input, $identity, $superuserPool], async ([rawInput, identity, superuserPool]) => {
69
+ if (identity?.kind !== "user") {
70
+ throw new GraphQLError("Unauthorized");
71
+ }
72
+ let input;
73
+ try {
74
+ input = InputSchema.parse(rawInput);
75
+ }
76
+ catch (e) {
77
+ if (e instanceof z.ZodError) {
78
+ throw new GraphQLError(extractFirstZodErrorMessage(e));
79
+ }
80
+ throw e;
81
+ }
82
+ return await withPgPoolTransaction(superuserPool, async (pgClient) => {
83
+ await PgAdvisoryLock.forChatUserAction(pgClient, {
84
+ userId: identity.session.user_id,
85
+ experienceId: identity.session.experience_id,
86
+ casinoId: identity.session.casino_id,
87
+ });
88
+ const dbMute = await pgClient
89
+ .query({
90
+ text: `
91
+ SELECT id
92
+ FROM hub.active_chat_mute
93
+ WHERE casino_id = $1
94
+ AND experience_id = $2
95
+ AND user_id = $3
96
+ `,
97
+ values: [
98
+ identity.session.casino_id,
99
+ identity.session.experience_id,
100
+ identity.session.user_id,
101
+ ],
102
+ })
103
+ .then(maybeOneRow);
104
+ if (dbMute) {
105
+ return {
106
+ __typename: "HubChatUserMuted",
107
+ message: "You are muted",
108
+ };
109
+ }
110
+ const dbChatMessage = await pgClient
111
+ .query({
112
+ text: `
113
+ with ins as (
114
+ insert into hub.chat_message (casino_id, experience_id, user_id, client_id, body, type)
115
+ values ($1, $2, $3, $4, $5, 'user')
116
+ on conflict (casino_id, experience_id, user_id, client_id) where type = 'user'
117
+ do nothing
118
+ returning id
119
+ )
120
+ select id from ins
121
+ union all
122
+ select id
123
+ from hub.chat_message
124
+ where casino_id = $1 and experience_id = $2 and user_id = $3 and client_id = $4
125
+ limit 1
126
+ `,
127
+ values: [
128
+ identity.session.casino_id,
129
+ identity.session.experience_id,
130
+ identity.session.user_id,
131
+ input.clientId,
132
+ input.body,
133
+ ],
134
+ })
135
+ .then(exactlyOneRow);
136
+ const dbRateLimit = await pgClient
137
+ .query({
138
+ text: `
139
+ with wins as (
140
+ select *
141
+ from unnest($4::int[], $5::int[]) as t(window_seconds, cap)
142
+ ),
143
+ bumped as (
144
+ insert into hub.chat_rate_bucket
145
+ (casino_id, experience_id, user_id, window_seconds, bucket_start, count)
146
+ select
147
+ $1, $2, $3,
148
+ w.window_seconds,
149
+ to_timestamp(floor(extract(epoch from now())/w.window_seconds)*w.window_seconds),
150
+ 1
151
+ from wins w
152
+ on conflict (casino_id, experience_id, user_id, window_seconds, bucket_start)
153
+ do update set count = hub.chat_rate_bucket.count + 1
154
+ returning window_seconds, count
155
+ )
156
+ select bool_or(b.count > w.cap) as exceeded
157
+ from bumped b
158
+ join wins w using (window_seconds)
159
+ `,
160
+ values: [
161
+ identity.session.casino_id,
162
+ identity.session.experience_id,
163
+ identity.session.user_id,
164
+ RL_WINDOWS,
165
+ RL_CAPS,
166
+ ],
167
+ })
168
+ .then(maybeOneRow);
169
+ if (dbRateLimit?.exceeded) {
170
+ return {
171
+ __typename: "HubChatUserRateLimited",
172
+ message: "Rate limit exceeded",
173
+ };
174
+ }
175
+ const notifyPayload = {
176
+ type: "message",
177
+ chat_message_id: dbChatMessage.id,
178
+ };
179
+ await pgClient.query({
180
+ text: `
181
+ select pg_notify('hub:chat:${identity.session.experience_id}', $1::text)
182
+ `,
183
+ values: [JSON.stringify(notifyPayload)],
184
+ });
185
+ return {
186
+ __typename: "HubChatCreateUserMessageSuccess",
187
+ chatMessageId: dbChatMessage.id,
188
+ };
189
+ });
190
+ });
191
+ return object({
192
+ result: $payload,
193
+ });
194
+ },
195
+ },
196
+ },
197
+ HubChatUserMuted: {
198
+ assertStep: ObjectStep,
199
+ },
200
+ HubChatUserRateLimited: {
201
+ assertStep: ObjectStep,
202
+ },
203
+ HubChatCreateUserMessageSuccess: {
204
+ assertStep: ObjectStep,
205
+ plans: {
206
+ chatMessage($data) {
207
+ const $id = access($data, "chatMessageId");
208
+ return chatMessageTable.get({ id: $id });
209
+ },
210
+ },
211
+ },
212
+ HubChatCreateUserMessagePayload: {
213
+ assertStep: ObjectStep,
214
+ plans: {
215
+ result($data) {
216
+ const $result = $data.get("result");
217
+ return $result;
218
+ },
219
+ },
220
+ },
221
+ },
222
+ };
223
+ });
224
+ function normalizeUserMessageBody(body) {
225
+ return (body
226
+ .normalize("NFC")
227
+ .replace(/(\p{M}{1,2})\p{M}+/gu, "$1")
228
+ .replace(/[\u200B-\u200D\uFEFF]/g, "")
229
+ .replace(/\s+/g, " ")
230
+ .trim());
231
+ }
@@ -0,0 +1 @@
1
+ export declare const HubChatMuteUserPlugin: GraphileConfig.Plugin;
@@ -0,0 +1,186 @@
1
+ import { GraphQLError } from "graphql";
2
+ import { context, object, sideEffect } from "postgraphile/grafast";
3
+ import { extendSchema, gql } from "postgraphile/utils";
4
+ import z from "zod/v4";
5
+ import { exactlyOneRow, maybeOneRow, withPgPoolTransaction, } from "../../db/index.js";
6
+ import { extractFirstZodErrorMessage, uuidEqual } from "../../util.js";
7
+ import { PgAdvisoryLock } from "../../pg-advisory-lock.js";
8
+ const InputSchema = z.object({
9
+ userId: z.uuid("Invalid user ID"),
10
+ expiredAt: z.iso
11
+ .datetime({ offset: true, error: "Invalid expired at" })
12
+ .optional()
13
+ .refine((value) => typeof value === "undefined" ||
14
+ (typeof value === "string" && new Date(value).getTime() > Date.now()), { error: "Expired at must be in the future" })
15
+ .transform((value) => (value ? new Date(value) : null)),
16
+ reason: z
17
+ .string("Invalid reason")
18
+ .trim()
19
+ .optional()
20
+ .refine((value) => typeof value === "undefined" ||
21
+ (typeof value === "string" && value.length > 0), { error: "Reason must be at least 1 character" })
22
+ .refine((value) => typeof value === "undefined" ||
23
+ (typeof value === "string" && value.length <= 140), { error: "Reason must be at most 140 characters" })
24
+ .transform((value) => (value ? value : null)),
25
+ });
26
+ export const HubChatMuteUserPlugin = extendSchema((build) => {
27
+ const chatMuteTable = build.input.pgRegistry.pgResources.hub_chat_mute;
28
+ return {
29
+ typeDefs: gql `
30
+ input HubChatMuteUserInput {
31
+ userId: UUID!
32
+ expiredAt: Datetime
33
+ reason: String
34
+ }
35
+
36
+ type HubChatMuteUserPayload {
37
+ chatMute: HubChatMute!
38
+ }
39
+
40
+ extend type Mutation {
41
+ hubChatMuteUser(input: HubChatMuteUserInput!): HubChatMuteUserPayload
42
+ }
43
+ `,
44
+ objects: {
45
+ Mutation: {
46
+ plans: {
47
+ hubChatMuteUser(_, { $input }) {
48
+ const $identity = context().get("identity");
49
+ const $superuserPool = context().get("superuserPool");
50
+ const $muteId = sideEffect([$input, $identity, $superuserPool], async ([rawInput, identity, superuserPool]) => {
51
+ if (identity?.kind !== "user") {
52
+ throw new GraphQLError("Unauthorized");
53
+ }
54
+ let input;
55
+ try {
56
+ input = InputSchema.parse(rawInput);
57
+ }
58
+ catch (e) {
59
+ if (e instanceof z.ZodError) {
60
+ throw new GraphQLError(extractFirstZodErrorMessage(e));
61
+ }
62
+ throw e;
63
+ }
64
+ const dbTargetUser = await superuserPool
65
+ .query({
66
+ text: `
67
+ SELECT id
68
+ FROM hub.user
69
+ WHERE casino_id = $1 AND id = $2
70
+ `,
71
+ values: [identity.session.casino_id, input.userId],
72
+ })
73
+ .then(maybeOneRow);
74
+ if (!dbTargetUser) {
75
+ throw new GraphQLError("User not found");
76
+ }
77
+ const dbCurrentUserChatMod = await superuserPool
78
+ .query({
79
+ text: `
80
+ SELECT id, user_id
81
+ FROM hub.chat_mod
82
+ WHERE casino_id = $1 AND experience_id = $2 AND user_id = $3
83
+ `,
84
+ values: [
85
+ identity.session.casino_id,
86
+ identity.session.experience_id,
87
+ identity.session.user_id,
88
+ ],
89
+ })
90
+ .then(maybeOneRow);
91
+ if (!dbCurrentUserChatMod &&
92
+ !identity.session.is_experience_owner) {
93
+ throw new GraphQLError("Unauthorized");
94
+ }
95
+ const dbExperience = await superuserPool
96
+ .query({
97
+ text: `
98
+ SELECT id, user_id
99
+ FROM hub.experience
100
+ WHERE id = $1 AND casino_id = $2
101
+ `,
102
+ values: [
103
+ identity.session.experience_id,
104
+ identity.session.casino_id,
105
+ ],
106
+ })
107
+ .then(exactlyOneRow);
108
+ const targetIsChatMod = await superuserPool
109
+ .query({
110
+ text: `
111
+ select 1
112
+ from hub.chat_mod
113
+ where casino_id = $1 and experience_id = $2 and user_id = $3
114
+ `,
115
+ values: [
116
+ identity.session.casino_id,
117
+ identity.session.experience_id,
118
+ dbTargetUser.id,
119
+ ],
120
+ })
121
+ .then(maybeOneRow);
122
+ const targetIsExperienceOwner = dbExperience.user_id &&
123
+ uuidEqual(dbExperience.user_id, dbTargetUser.id);
124
+ if (targetIsExperienceOwner || targetIsChatMod) {
125
+ throw new GraphQLError("Cannot mute staff");
126
+ }
127
+ if (uuidEqual(identity.session.user_id, dbTargetUser.id)) {
128
+ throw new GraphQLError("Cannot mute yourself");
129
+ }
130
+ const muteId = await withPgPoolTransaction(superuserPool, async (pgClient) => {
131
+ await PgAdvisoryLock.forChatUserAction(pgClient, {
132
+ userId: dbTargetUser.id,
133
+ experienceId: identity.session.experience_id,
134
+ casinoId: identity.session.casino_id,
135
+ });
136
+ await pgClient.query(`
137
+ UPDATE hub.chat_mute
138
+ SET revoked_at = now()
139
+ WHERE user_id = $1
140
+ AND experience_id = $2
141
+ AND casino_id = $3
142
+ AND revoked_at IS NULL
143
+ `, [
144
+ dbTargetUser.id,
145
+ identity.session.experience_id,
146
+ identity.session.casino_id,
147
+ ]);
148
+ const dbMute = await pgClient
149
+ .query({
150
+ text: `
151
+ INSERT INTO hub.chat_mute (casino_id, experience_id, user_id, reason, expired_at)
152
+ VALUES ($1, $2, $3, $4, $5)
153
+ RETURNING id
154
+ `,
155
+ values: [
156
+ identity.session.casino_id,
157
+ identity.session.experience_id,
158
+ dbTargetUser.id,
159
+ input.reason,
160
+ input.expiredAt,
161
+ ],
162
+ })
163
+ .then(exactlyOneRow);
164
+ const notifyPayload = {
165
+ type: "mute",
166
+ user_id: dbTargetUser.id,
167
+ };
168
+ await pgClient.query({
169
+ text: `
170
+ SELECT pg_notify('hub:chat:${identity.session.experience_id}', $1::text)
171
+ `,
172
+ values: [JSON.stringify(notifyPayload)],
173
+ });
174
+ return dbMute.id;
175
+ });
176
+ return muteId;
177
+ });
178
+ return object({
179
+ chatMute: chatMuteTable.get({ id: $muteId }),
180
+ });
181
+ },
182
+ },
183
+ },
184
+ },
185
+ };
186
+ });
@@ -0,0 +1,14 @@
1
+ import z from "zod/v4";
2
+ declare const ChatPgNotifyPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
3
+ type: z.ZodLiteral<"message">;
4
+ chat_message_id: z.ZodString;
5
+ }, z.core.$strip>, z.ZodObject<{
6
+ type: z.ZodLiteral<"mute">;
7
+ user_id: z.ZodString;
8
+ }, z.core.$strip>, z.ZodObject<{
9
+ type: z.ZodLiteral<"unmute">;
10
+ user_id: z.ZodString;
11
+ }, z.core.$strip>]>;
12
+ export type ChatPgNotifyPayload = z.infer<typeof ChatPgNotifyPayloadSchema>;
13
+ export declare const HubChatSubscriptionPlugin: GraphileConfig.Plugin;
14
+ export {};
@@ -0,0 +1,133 @@
1
+ import { access, context, lambda, ObjectStep } from "postgraphile/grafast";
2
+ import { extendSchema, gql } from "postgraphile/utils";
3
+ import { jsonParse } from "postgraphile/@dataplan/json";
4
+ import { listenWithFilter } from "../listen-with-filter.js";
5
+ import { logger } from "../../logger.js";
6
+ import z, { prettifyError } from "zod/v4";
7
+ const ChatPgNotifyPayloadSchema = z.discriminatedUnion("type", [
8
+ z.object({
9
+ type: z.literal("message"),
10
+ chat_message_id: z.string(),
11
+ }),
12
+ z.object({
13
+ type: z.literal("mute"),
14
+ user_id: z.string(),
15
+ }),
16
+ z.object({
17
+ type: z.literal("unmute"),
18
+ user_id: z.string(),
19
+ }),
20
+ ]);
21
+ export const HubChatSubscriptionPlugin = extendSchema((build) => {
22
+ const chatMessageTable = build.input.pgRegistry.pgResources.hub_chat_message;
23
+ return {
24
+ typeDefs: gql `
25
+ type HubChatSubscriptionNewMessage {
26
+ chatMessage: HubChatMessage!
27
+ }
28
+
29
+ type HubChatSubscriptionMuted {
30
+ _: Boolean
31
+ }
32
+
33
+ type HubChatSubscriptionUnmuted {
34
+ _: Boolean
35
+ }
36
+
37
+ union HubChatSubscriptionPayload =
38
+ HubChatSubscriptionNewMessage
39
+ | HubChatSubscriptionMuted
40
+ | HubChatSubscriptionUnmuted
41
+
42
+ extend type Subscription {
43
+ hubChatAlert: HubChatSubscriptionPayload
44
+ }
45
+ `,
46
+ objects: {
47
+ HubChatSubscriptionMuted: {
48
+ assertStep: ObjectStep,
49
+ },
50
+ HubChatSubscriptionUnmuted: {
51
+ assertStep: ObjectStep,
52
+ },
53
+ HubChatSubscriptionNewMessage: {
54
+ assertStep: ObjectStep,
55
+ plans: {
56
+ chatMessage($data) {
57
+ const $id = access($data, "chatMessageId");
58
+ return chatMessageTable.get({ id: $id });
59
+ },
60
+ },
61
+ },
62
+ Subscription: {
63
+ plans: {
64
+ hubChatAlert: {
65
+ subscribePlan(_$root) {
66
+ const $pgSubscriber = context().get("pgSubscriber");
67
+ const $identity = context().get("identity");
68
+ const $channelKey = lambda($identity, (identity) => {
69
+ if (identity?.kind !== "user") {
70
+ return "";
71
+ }
72
+ return `hub:chat:${identity.session.experience_id}`;
73
+ });
74
+ return listenWithFilter($pgSubscriber, $channelKey, jsonParse, $identity, (item, identity) => {
75
+ if (identity?.kind !== "user") {
76
+ return false;
77
+ }
78
+ if (typeof item !== "string") {
79
+ logger.warn(item, `hubChatAlert: item is not a string`);
80
+ return false;
81
+ }
82
+ const payload = JSON.parse(item);
83
+ switch (payload.type) {
84
+ case "message":
85
+ break;
86
+ case "mute":
87
+ case "unmute":
88
+ return payload.user_id === identity.session.user_id;
89
+ }
90
+ return true;
91
+ });
92
+ },
93
+ plan($event) {
94
+ return lambda($event, (event) => {
95
+ let payload;
96
+ try {
97
+ payload = ChatPgNotifyPayloadSchema.parse(event);
98
+ }
99
+ catch (e) {
100
+ if (e instanceof z.ZodError) {
101
+ throw new Error(`Unexpected payload shape: ${prettifyError(e)}`);
102
+ }
103
+ throw e;
104
+ }
105
+ switch (payload.type) {
106
+ case "message":
107
+ return {
108
+ __typename: "HubChatSubscriptionNewMessage",
109
+ chatMessageId: payload.chat_message_id,
110
+ };
111
+ case "mute":
112
+ return {
113
+ __typename: "HubChatSubscriptionMuted",
114
+ _: true,
115
+ };
116
+ case "unmute":
117
+ return {
118
+ __typename: "HubChatSubscriptionUnmuted",
119
+ _: true,
120
+ };
121
+ default: {
122
+ const _exhaustiveCheck = payload;
123
+ throw new Error(`Unexpected payload type: ${_exhaustiveCheck}`);
124
+ }
125
+ }
126
+ });
127
+ },
128
+ },
129
+ },
130
+ },
131
+ },
132
+ };
133
+ });
@@ -0,0 +1 @@
1
+ export declare const HubChatUnmuteUserPlugin: GraphileConfig.Plugin;