@moneypot/hub 1.20.6 → 1.22.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.
- package/dist/cli/add-casino.js +1 -1
- package/dist/cli/db-migrate.js +0 -0
- package/dist/dashboard/assets/{index-0G7cFk_0.js → index-n_xD3-qc.js} +48 -48
- package/dist/dashboard/index.html +1 -1
- package/dist/src/audit-log.d.ts +1 -1
- package/dist/src/config.js +2 -3
- package/dist/src/log-serializers.d.ts +4 -0
- package/dist/src/log-serializers.js +118 -0
- package/dist/src/logger.js +4 -0
- package/dist/src/pg-versions/018-scope-chat-owner-reads.sql +49 -0
- package/dist/src/plugins/chat/hub-chat-create-system-message.js +5 -1
- package/dist/src/plugins/chat/hub-chat-mod-management.js +4 -4
- package/dist/src/plugins/graphql-error-log.d.ts +28 -0
- package/dist/src/plugins/graphql-error-log.js +103 -0
- package/dist/src/plugins/hub-add-casino.js +1 -1
- package/dist/src/plugins/hub-adjust-bankroll.d.ts +1 -0
- package/dist/src/plugins/hub-adjust-bankroll.js +106 -0
- package/dist/src/plugins/validate-fields.js +0 -8
- package/dist/src/process-transfers/index.js +0 -44
- package/dist/src/process-transfers/polling-processor.js +45 -21
- package/dist/src/process-transfers/process-transfer.js +0 -4
- package/dist/src/process-transfers/websocket-processor.js +1 -1
- package/dist/src/server/graphile.config.js +4 -0
- package/dist/src/smart-tags.js +7 -7
- package/dist/src/take-request/process-take-request.js +87 -44
- package/package.json +20 -23
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Dashboard</title>
|
|
7
|
-
<script type="module" crossorigin src="/dashboard/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/dashboard/assets/index-n_xD3-qc.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-Bl5lLFHa.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/dist/src/audit-log.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DbPlayerBalance, DbHouseBankroll } from "./db/types.js";
|
|
2
2
|
import { QueryExecutor } from "./db/util.js";
|
|
3
3
|
export type AuditLogType = "player-balance" | "house-bankroll" | "both";
|
|
4
|
-
export type HubAuditAction = "hub:init" | "hub:deposit" | "hub:take_request:pending" | "hub:take_request:refund" | "hub:outcome_bet";
|
|
4
|
+
export type HubAuditAction = "hub:init" | "hub:deposit" | "hub:take_request:pending" | "hub:take_request:refund" | "hub:outcome_bet" | "hub:bankroll:adjust";
|
|
5
5
|
export type HubAuditRefType = "hub.deposit" | "hub.outcome_bet" | "hub.take_request";
|
|
6
6
|
export type AuditAction = HubAuditAction | (string & {});
|
|
7
7
|
export type AuditRefType = HubAuditRefType | (string & {});
|
package/dist/src/config.js
CHANGED
|
@@ -5,9 +5,8 @@ function getEnvVariable(key, transform = (value) => value) {
|
|
|
5
5
|
return transform(process.env[key] || "");
|
|
6
6
|
}
|
|
7
7
|
export const NODE_ENV = getEnvVariable("NODE_ENV", (value) => {
|
|
8
|
-
if (
|
|
9
|
-
|
|
10
|
-
return "development";
|
|
8
|
+
if (value !== "development" && value !== "test" && value !== "production") {
|
|
9
|
+
throw new Error(`NODE_ENV must be "development", "test", or "production" (got ${JSON.stringify(value)})`);
|
|
11
10
|
}
|
|
12
11
|
return value;
|
|
13
12
|
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import pino from "pino";
|
|
2
|
+
import { ClientError } from "graphql-request";
|
|
3
|
+
const SAFE_CODE = /^[A-Z0-9_]{1,32}$/;
|
|
4
|
+
const OPERATION_NAME = /^\s*(?:query|mutation|subscription)\s+([_A-Za-z][_0-9A-Za-z]{0,99})\b/;
|
|
5
|
+
const MAX_DEPTH = 5;
|
|
6
|
+
const WITHHELD_MESSAGE = "error details withheld: a casino request failed";
|
|
7
|
+
function isClientError(value) {
|
|
8
|
+
return (value instanceof Error &&
|
|
9
|
+
typeof value.response === "object" &&
|
|
10
|
+
value.response !== null &&
|
|
11
|
+
typeof value.request === "object");
|
|
12
|
+
}
|
|
13
|
+
function nestedErrors(err) {
|
|
14
|
+
const record = err;
|
|
15
|
+
const nested = [err.cause, record.originalError];
|
|
16
|
+
if (Array.isArray(record.errors)) {
|
|
17
|
+
nested.push(...record.errors);
|
|
18
|
+
}
|
|
19
|
+
for (const key in err) {
|
|
20
|
+
nested.push(record[key]);
|
|
21
|
+
}
|
|
22
|
+
return nested.filter((x) => x instanceof Error && x !== err);
|
|
23
|
+
}
|
|
24
|
+
function containsClientError(err, depth = 0) {
|
|
25
|
+
if (!(err instanceof Error)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (depth > MAX_DEPTH) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return (isClientError(err) ||
|
|
32
|
+
nestedErrors(err).some((x) => containsClientError(x, depth + 1)));
|
|
33
|
+
}
|
|
34
|
+
const LOCATION = String.raw `(?:file:\/\/|node:|\/)[^\s()]+:\d+:\d+`;
|
|
35
|
+
const STACK_FRAME = new RegExp(String.raw `^\s+at (?:(?:async |new )?[^\s()]+(?: \[as [^\s\]]+\])? \((?:${LOCATION}|<anonymous>|index \d+)\)|(?:async )?${LOCATION})$`);
|
|
36
|
+
function stackFrames(err) {
|
|
37
|
+
const header = String(err);
|
|
38
|
+
if (typeof err.stack !== "string" || !err.stack.startsWith(`${header}\n`)) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const lines = err.stack.slice(header.length + 1).split("\n");
|
|
42
|
+
return lines.every((line) => STACK_FRAME.test(line))
|
|
43
|
+
? lines.map((line) => line.trim())
|
|
44
|
+
: undefined;
|
|
45
|
+
}
|
|
46
|
+
function operationName(err) {
|
|
47
|
+
const query = err.request
|
|
48
|
+
?.query;
|
|
49
|
+
return typeof query === "string"
|
|
50
|
+
? OPERATION_NAME.exec(query)?.[1]
|
|
51
|
+
: undefined;
|
|
52
|
+
}
|
|
53
|
+
function describeClientError(err) {
|
|
54
|
+
const { status, errors } = err.response;
|
|
55
|
+
const list = Array.isArray(errors) ? errors : [];
|
|
56
|
+
return {
|
|
57
|
+
type: "ClientError",
|
|
58
|
+
operationName: operationName(err),
|
|
59
|
+
status: typeof status === "number" ? status : undefined,
|
|
60
|
+
errorCount: list.length,
|
|
61
|
+
codes: list.flatMap((e) => {
|
|
62
|
+
const code = e?.extensions
|
|
63
|
+
?.code;
|
|
64
|
+
return typeof code === "string" && SAFE_CODE.test(code) ? [code] : [];
|
|
65
|
+
}),
|
|
66
|
+
frames: err instanceof ClientError ? stackFrames(err) : undefined,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function describeTaintedError(err, depth) {
|
|
70
|
+
if (isClientError(err)) {
|
|
71
|
+
return describeClientError(err);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
type: err.constructor?.name ?? "Error",
|
|
75
|
+
withheld: true,
|
|
76
|
+
...(depth < MAX_DEPTH
|
|
77
|
+
? {
|
|
78
|
+
nested: nestedErrors(err).map((x) => describeTaintedError(x, depth + 1)),
|
|
79
|
+
}
|
|
80
|
+
: {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function serializeError(err) {
|
|
84
|
+
if (!(err instanceof Error)) {
|
|
85
|
+
return err;
|
|
86
|
+
}
|
|
87
|
+
return containsClientError(err)
|
|
88
|
+
? describeTaintedError(err, 0)
|
|
89
|
+
: pino.stdSerializers.err(err);
|
|
90
|
+
}
|
|
91
|
+
const withholdingHooks = {
|
|
92
|
+
logMethod(args, method) {
|
|
93
|
+
const [first, second] = args;
|
|
94
|
+
const error = first instanceof Error
|
|
95
|
+
? first
|
|
96
|
+
: (first?.err ??
|
|
97
|
+
first?.error);
|
|
98
|
+
if (typeof second !== "string" && containsClientError(error)) {
|
|
99
|
+
const object = first instanceof Error ? { err: first } : first;
|
|
100
|
+
return method.apply(this, [object, WITHHELD_MESSAGE]);
|
|
101
|
+
}
|
|
102
|
+
return method.apply(this, args);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
export function errorLogOptions(options) {
|
|
106
|
+
if (options.full) {
|
|
107
|
+
return {
|
|
108
|
+
serializers: {
|
|
109
|
+
err: pino.stdSerializers.err,
|
|
110
|
+
error: pino.stdSerializers.err,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
serializers: { err: serializeError, error: serializeError },
|
|
116
|
+
hooks: withholdingHooks,
|
|
117
|
+
};
|
|
118
|
+
}
|
package/dist/src/logger.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import pino from "pino";
|
|
2
2
|
import { LOG_LEVEL, LOG_PRETTY, NODE_ENV } from "./config.js";
|
|
3
|
+
import { errorLogOptions } from "./log-serializers.js";
|
|
3
4
|
function createLogger(options) {
|
|
4
5
|
const prettify = LOG_PRETTY === undefined ? NODE_ENV !== "production" : LOG_PRETTY;
|
|
5
6
|
if (prettify) {
|
|
6
7
|
try {
|
|
7
8
|
return pino({
|
|
8
9
|
level: options.level,
|
|
10
|
+
...errorLogOptions({ full: NODE_ENV === "development" }),
|
|
9
11
|
transport: {
|
|
10
12
|
target: "pino-pretty",
|
|
11
13
|
options: {
|
|
@@ -18,12 +20,14 @@ function createLogger(options) {
|
|
|
18
20
|
console.warn("LOG_PRETTY=true but pino-pretty is not installed, falling back to standard logging");
|
|
19
21
|
return pino({
|
|
20
22
|
level: options.level,
|
|
23
|
+
...errorLogOptions({ full: NODE_ENV === "development" }),
|
|
21
24
|
});
|
|
22
25
|
}
|
|
23
26
|
}
|
|
24
27
|
else {
|
|
25
28
|
return pino({
|
|
26
29
|
level: options.level,
|
|
30
|
+
...errorLogOptions({ full: NODE_ENV === "development" }),
|
|
27
31
|
});
|
|
28
32
|
}
|
|
29
33
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
|
|
2
|
+
drop policy select_chat_message on hub.chat_message;
|
|
3
|
+
create policy select_chat_message on hub.chat_message for select using (
|
|
4
|
+
hub_hidden.is_operator()
|
|
5
|
+
|
|
6
|
+
or (
|
|
7
|
+
hidden_at is null
|
|
8
|
+
and experience_id = hub_hidden.current_experience_id()
|
|
9
|
+
and casino_id = hub_hidden.current_casino_id()
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
or (
|
|
13
|
+
hub_hidden.is_experience_owner()
|
|
14
|
+
and experience_id = hub_hidden.current_experience_id()
|
|
15
|
+
and casino_id = hub_hidden.current_casino_id()
|
|
16
|
+
)
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
drop policy select_chat_mute on hub.chat_mute;
|
|
20
|
+
create policy select_chat_mute on hub.chat_mute for select using (
|
|
21
|
+
hub_hidden.is_operator()
|
|
22
|
+
|
|
23
|
+
or hub_hidden.current_user_id() = hub.chat_mute.user_id
|
|
24
|
+
|
|
25
|
+
or (
|
|
26
|
+
hub_hidden.is_experience_owner()
|
|
27
|
+
and hub.chat_mute.experience_id = hub_hidden.current_experience_id()
|
|
28
|
+
and hub.chat_mute.casino_id = hub_hidden.current_casino_id()
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
or exists (
|
|
32
|
+
select 1
|
|
33
|
+
from hub.chat_mod
|
|
34
|
+
where user_id = hub_hidden.current_user_id()
|
|
35
|
+
and casino_id = hub.chat_mute.casino_id
|
|
36
|
+
and experience_id = hub.chat_mute.experience_id
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
drop policy select_chat_mod on hub.chat_mod;
|
|
41
|
+
create policy select_chat_mod on hub.chat_mod for select using (
|
|
42
|
+
hub_hidden.is_operator()
|
|
43
|
+
|
|
44
|
+
or (
|
|
45
|
+
hub_hidden.is_experience_owner()
|
|
46
|
+
and experience_id = hub_hidden.current_experience_id()
|
|
47
|
+
and casino_id = hub_hidden.current_casino_id()
|
|
48
|
+
)
|
|
49
|
+
);
|
|
@@ -2,7 +2,7 @@ import { GraphQLError } from "graphql";
|
|
|
2
2
|
import { context, object, sideEffect } from "postgraphile/grafast";
|
|
3
3
|
import { extendSchema, gql } from "postgraphile/utils";
|
|
4
4
|
import z from "zod/v4";
|
|
5
|
-
import { extractFirstZodErrorMessage } from "../../util.js";
|
|
5
|
+
import { extractFirstZodErrorMessage, uuidEqual } from "../../util.js";
|
|
6
6
|
import { exactlyOneRow, maybeOneRow, withPgPoolTransaction, } from "../../db/index.js";
|
|
7
7
|
const InputSchema = z.object({
|
|
8
8
|
clientId: z.uuid("Invalid client ID"),
|
|
@@ -69,6 +69,10 @@ export const HubChatCreateSystemMessagePlugin = extendSchema((build) => {
|
|
|
69
69
|
if (!dbExperience) {
|
|
70
70
|
throw new GraphQLError("Experience not found");
|
|
71
71
|
}
|
|
72
|
+
if (identity.kind === "user" &&
|
|
73
|
+
!uuidEqual(identity.session.experience_id, dbExperience.id)) {
|
|
74
|
+
throw new GraphQLError("Your session does not match the experienceId");
|
|
75
|
+
}
|
|
72
76
|
return await withPgPoolTransaction(superuserPool, async (pgClient) => {
|
|
73
77
|
const dbChatMessage = await pgClient
|
|
74
78
|
.query({
|
|
@@ -85,9 +85,9 @@ export const HubChatModManagementPlugin = extendSchema((build) => {
|
|
|
85
85
|
text: `
|
|
86
86
|
SELECT id
|
|
87
87
|
FROM hub.user
|
|
88
|
-
WHERE id = $1 AND
|
|
88
|
+
WHERE id = $1 AND casino_id = $2
|
|
89
89
|
`,
|
|
90
|
-
values: [input.userId,
|
|
90
|
+
values: [input.userId, dbTargetExperience.casino_id],
|
|
91
91
|
})
|
|
92
92
|
.then(maybeOneRow);
|
|
93
93
|
if (!dbTargetUser) {
|
|
@@ -167,9 +167,9 @@ export const HubChatModManagementPlugin = extendSchema((build) => {
|
|
|
167
167
|
text: `
|
|
168
168
|
SELECT id
|
|
169
169
|
FROM hub.user
|
|
170
|
-
WHERE id = $1 AND
|
|
170
|
+
WHERE id = $1 AND casino_id = $2
|
|
171
171
|
`,
|
|
172
|
-
values: [input.userId,
|
|
172
|
+
values: [input.userId, dbTargetExperience.casino_id],
|
|
173
173
|
})
|
|
174
174
|
.then(maybeOneRow);
|
|
175
175
|
if (!dbTargetUser) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
type ResponseError = {
|
|
2
|
+
message?: unknown;
|
|
3
|
+
path?: unknown;
|
|
4
|
+
locations?: unknown;
|
|
5
|
+
extensions?: {
|
|
6
|
+
code?: unknown;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
type ResponsePayload = {
|
|
10
|
+
data?: unknown;
|
|
11
|
+
errors?: ResponseError[];
|
|
12
|
+
};
|
|
13
|
+
export type GraphQLErrorLogLine = {
|
|
14
|
+
operationName: string | null;
|
|
15
|
+
kind: "parse" | "request" | "execution";
|
|
16
|
+
errorCount: number;
|
|
17
|
+
codes: string[];
|
|
18
|
+
locations: (string | null)[];
|
|
19
|
+
unsafe?: {
|
|
20
|
+
message: unknown;
|
|
21
|
+
path: unknown;
|
|
22
|
+
}[];
|
|
23
|
+
};
|
|
24
|
+
export declare function summarizeGraphQLErrors(payload: ResponsePayload, rawOperationName: unknown, options: {
|
|
25
|
+
includeUnsafe: boolean;
|
|
26
|
+
}): GraphQLErrorLogLine | null;
|
|
27
|
+
export declare const GraphQLErrorLogPlugin: GraphileConfig.Plugin;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { NODE_ENV } from "../config.js";
|
|
2
|
+
import { logger } from "../logger.js";
|
|
3
|
+
const SAFE_CODE = /^[A-Z0-9_]{1,32}$/;
|
|
4
|
+
const MAX_INSPECTED_RESPONSE_BYTES = 256 * 1024;
|
|
5
|
+
const SAFE_OPERATION_NAME = /^[_A-Za-z][_0-9A-Za-z]*$/;
|
|
6
|
+
const MAX_OPERATION_NAME_LENGTH = 100;
|
|
7
|
+
const MAX_ERRORS_INSPECTED = 20;
|
|
8
|
+
export function summarizeGraphQLErrors(payload, rawOperationName, options) {
|
|
9
|
+
const errors = Array.isArray(payload.errors) ? payload.errors : [];
|
|
10
|
+
if (errors.length === 0) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const inspected = errors.slice(0, MAX_ERRORS_INSPECTED);
|
|
14
|
+
const locations = inspected.map((e) => firstLocation(e?.locations));
|
|
15
|
+
const codes = [
|
|
16
|
+
...new Set(inspected.flatMap((e) => {
|
|
17
|
+
const code = e?.extensions?.code;
|
|
18
|
+
return typeof code === "string" && SAFE_CODE.test(code) ? [code] : [];
|
|
19
|
+
})),
|
|
20
|
+
];
|
|
21
|
+
const isParseError = inspected.some((e) => typeof e?.message === "string" && e.message.startsWith("Syntax Error"));
|
|
22
|
+
return {
|
|
23
|
+
operationName: safeOperationName(rawOperationName),
|
|
24
|
+
kind: isParseError ? "parse" : "data" in payload ? "execution" : "request",
|
|
25
|
+
errorCount: errors.length,
|
|
26
|
+
codes,
|
|
27
|
+
locations,
|
|
28
|
+
...(options.includeUnsafe
|
|
29
|
+
? {
|
|
30
|
+
unsafe: inspected.map((e) => ({
|
|
31
|
+
message: e?.message,
|
|
32
|
+
path: e?.path,
|
|
33
|
+
})),
|
|
34
|
+
}
|
|
35
|
+
: {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function firstLocation(locations) {
|
|
39
|
+
const first = Array.isArray(locations) ? locations[0] : undefined;
|
|
40
|
+
const { line, column } = (first ?? {});
|
|
41
|
+
return Number.isSafeInteger(line) &&
|
|
42
|
+
Number.isSafeInteger(column) &&
|
|
43
|
+
line > 0 &&
|
|
44
|
+
column > 0
|
|
45
|
+
? `${line}:${column}`
|
|
46
|
+
: null;
|
|
47
|
+
}
|
|
48
|
+
function safeOperationName(name) {
|
|
49
|
+
if (typeof name !== "string" || name === "") {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return name.length <= MAX_OPERATION_NAME_LENGTH &&
|
|
53
|
+
SAFE_OPERATION_NAME.test(name)
|
|
54
|
+
? name
|
|
55
|
+
: "invalid";
|
|
56
|
+
}
|
|
57
|
+
function responsePayload(result) {
|
|
58
|
+
if (result?.type === "json") {
|
|
59
|
+
return result.json;
|
|
60
|
+
}
|
|
61
|
+
if (result?.type === "buffer" &&
|
|
62
|
+
result.buffer.length <= MAX_INSPECTED_RESPONSE_BYTES &&
|
|
63
|
+
result.buffer.includes('"errors"')) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(result.buffer.toString("utf8"));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const operationNames = new WeakMap();
|
|
74
|
+
export const GraphQLErrorLogPlugin = {
|
|
75
|
+
name: "HubGraphQLErrorLogPlugin",
|
|
76
|
+
description: "Logs GraphQL requests that return errors, without secrets",
|
|
77
|
+
version: "0.0.1",
|
|
78
|
+
grafserv: {
|
|
79
|
+
middleware: {
|
|
80
|
+
processGraphQLRequestBody(next, event) {
|
|
81
|
+
if (event.request) {
|
|
82
|
+
operationNames.set(event.request, event.body.operationName);
|
|
83
|
+
}
|
|
84
|
+
return next();
|
|
85
|
+
},
|
|
86
|
+
async processRequest(next, event) {
|
|
87
|
+
const result = await next();
|
|
88
|
+
try {
|
|
89
|
+
const payload = responsePayload(result);
|
|
90
|
+
const line = payload &&
|
|
91
|
+
summarizeGraphQLErrors(payload, operationNames.get(event.requestDigest), { includeUnsafe: NODE_ENV === "development" });
|
|
92
|
+
if (line) {
|
|
93
|
+
logger.warn(line, "GraphQL request returned errors");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
logger.warn({ errorType: e instanceof Error ? e.constructor.name : typeof e }, "GraphQL error logging failed");
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
@@ -56,8 +56,8 @@ export const HubAddCasinoPlugin = makeExtendSchemaPlugin((build) => {
|
|
|
56
56
|
throw new GraphQLError("Unauthorized");
|
|
57
57
|
}
|
|
58
58
|
assert(is(input));
|
|
59
|
-
logger.info(input, "Input");
|
|
60
59
|
const { name, baseUrl, graphqlUrl, apiKey } = input;
|
|
60
|
+
logger.info({ name, baseUrl, graphqlUrl }, "Input");
|
|
61
61
|
const graphqlClient = new GraphQLClient(graphqlUrl, {
|
|
62
62
|
headers: {
|
|
63
63
|
Authorization: `apikey:${apiKey}`,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const HubAdjustBankrollPlugin: GraphileConfig.Plugin;
|
|
@@ -0,0 +1,106 @@
|
|
|
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 { extractFirstZodErrorMessage } from "../util.js";
|
|
6
|
+
import { maybeOneRow, withPgPoolTransaction, } from "../db/index.js";
|
|
7
|
+
import { insertAuditLog } from "../audit-log.js";
|
|
8
|
+
const InputSchema = z.object({
|
|
9
|
+
bankrollId: z.uuid("Invalid bankroll ID"),
|
|
10
|
+
delta: z
|
|
11
|
+
.number()
|
|
12
|
+
.int("Delta must be a whole number of base units")
|
|
13
|
+
.refine((delta) => delta !== 0, "Delta must not be zero"),
|
|
14
|
+
reason: z
|
|
15
|
+
.string()
|
|
16
|
+
.trim()
|
|
17
|
+
.min(1, "Reason is required")
|
|
18
|
+
.max(200, "Max reason length: 200 chars"),
|
|
19
|
+
});
|
|
20
|
+
export const HubAdjustBankrollPlugin = extendSchema((build) => {
|
|
21
|
+
const bankrollTable = build.input.pgRegistry.pgResources.hub_bankroll;
|
|
22
|
+
return {
|
|
23
|
+
typeDefs: gql `
|
|
24
|
+
input HubAdjustBankrollInput {
|
|
25
|
+
bankrollId: UUID!
|
|
26
|
+
"Amount to add, in base units. Negative takes money out."
|
|
27
|
+
delta: Float!
|
|
28
|
+
reason: String!
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type HubAdjustBankrollPayload {
|
|
32
|
+
hubBankroll: HubBankroll!
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
extend type Mutation {
|
|
36
|
+
hubAdjustBankroll(
|
|
37
|
+
input: HubAdjustBankrollInput!
|
|
38
|
+
): HubAdjustBankrollPayload
|
|
39
|
+
}
|
|
40
|
+
`,
|
|
41
|
+
objects: {
|
|
42
|
+
Mutation: {
|
|
43
|
+
plans: {
|
|
44
|
+
hubAdjustBankroll(_, { $input }) {
|
|
45
|
+
const $identity = context().get("identity");
|
|
46
|
+
const $superuserPool = context().get("superuserPool");
|
|
47
|
+
const $bankrollId = sideEffect([$input, $identity, $superuserPool], async ([rawInput, identity, superuserPool]) => {
|
|
48
|
+
if (identity?.kind !== "operator") {
|
|
49
|
+
throw new GraphQLError("Unauthorized");
|
|
50
|
+
}
|
|
51
|
+
let input;
|
|
52
|
+
try {
|
|
53
|
+
input = InputSchema.parse(rawInput);
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
if (e instanceof z.ZodError) {
|
|
57
|
+
throw new GraphQLError(extractFirstZodErrorMessage(e));
|
|
58
|
+
}
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
return withPgPoolTransaction(superuserPool, async (pgClient) => {
|
|
62
|
+
const dbBankroll = await pgClient
|
|
63
|
+
.query({
|
|
64
|
+
text: `
|
|
65
|
+
SELECT id, amount, liability
|
|
66
|
+
FROM hub.bankroll
|
|
67
|
+
WHERE id = $1
|
|
68
|
+
FOR UPDATE
|
|
69
|
+
`,
|
|
70
|
+
values: [input.bankrollId],
|
|
71
|
+
})
|
|
72
|
+
.then(maybeOneRow);
|
|
73
|
+
if (!dbBankroll) {
|
|
74
|
+
throw new GraphQLError("Bankroll not found");
|
|
75
|
+
}
|
|
76
|
+
const newAmount = dbBankroll.amount + input.delta;
|
|
77
|
+
if (newAmount > Number.MAX_SAFE_INTEGER) {
|
|
78
|
+
throw new GraphQLError("Bankroll would be too large");
|
|
79
|
+
}
|
|
80
|
+
if (newAmount < dbBankroll.liability) {
|
|
81
|
+
throw new GraphQLError("Bankroll can't go below what open games may pay out");
|
|
82
|
+
}
|
|
83
|
+
await pgClient.query({
|
|
84
|
+
text: `UPDATE hub.bankroll SET amount = $2 WHERE id = $1`,
|
|
85
|
+
values: [dbBankroll.id, newAmount],
|
|
86
|
+
});
|
|
87
|
+
await insertAuditLog(pgClient, "house-bankroll", {
|
|
88
|
+
action: "hub:bankroll:adjust",
|
|
89
|
+
bankrollId: dbBankroll.id,
|
|
90
|
+
bankrollOld: dbBankroll.amount,
|
|
91
|
+
bankrollNew: newAmount,
|
|
92
|
+
bankrollDelta: input.delta,
|
|
93
|
+
metadata: { reason: input.reason },
|
|
94
|
+
});
|
|
95
|
+
return dbBankroll.id;
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
return object({
|
|
99
|
+
hubBankroll: bankrollTable.get({ id: $bankrollId }),
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}, "HubAdjustBankrollPlugin");
|
|
@@ -29,9 +29,6 @@ export const AddCasinoSchema = z.strictObject({
|
|
|
29
29
|
graphqlUrl: v.graphqlUrl(),
|
|
30
30
|
apiKey: z.uuid(),
|
|
31
31
|
});
|
|
32
|
-
const UpdateBankrollSchema = z.strictObject({
|
|
33
|
-
amount: z.number().int().min(0),
|
|
34
|
-
});
|
|
35
32
|
export const ValidateCasinoFieldsPlugin = wrapPlans(() => ({
|
|
36
33
|
Mutation: {
|
|
37
34
|
updateHubCasinoById: (plan, $source, fieldArgs) => {
|
|
@@ -44,10 +41,5 @@ export const ValidateCasinoFieldsPlugin = wrapPlans(() => ({
|
|
|
44
41
|
sideEffect($input, (input) => validate(input, AddCasinoSchema));
|
|
45
42
|
return plan();
|
|
46
43
|
},
|
|
47
|
-
updateHubBankrollById: (plan, $source, fieldArgs) => {
|
|
48
|
-
const $patch = fieldArgs.getRaw(["input", "hubBankrollPatch"]);
|
|
49
|
-
sideEffect($patch, (patch) => validate(patch, UpdateBankrollSchema));
|
|
50
|
-
return plan();
|
|
51
|
-
},
|
|
52
44
|
},
|
|
53
45
|
}));
|
|
@@ -5,8 +5,6 @@ import assert from "assert";
|
|
|
5
5
|
import { logger } from "../logger.js";
|
|
6
6
|
import * as config from "../config.js";
|
|
7
7
|
import * as db from "../db/index.js";
|
|
8
|
-
import * as pg from "pg";
|
|
9
|
-
import { z } from "zod/v4";
|
|
10
8
|
const activeCasinos = new Set();
|
|
11
9
|
export async function startCasinoTransferProcessor({ casinoId, signal, pool, }) {
|
|
12
10
|
if (activeCasinos.has(casinoId)) {
|
|
@@ -44,51 +42,9 @@ export function initializeTransferProcessors({ signal, pool, }) {
|
|
|
44
42
|
logger.info(`Starting casino processor for "${casino.name}" at "${casino.graphql_url}"`);
|
|
45
43
|
startCasinoTransferProcessor({ casinoId: casino.id, signal, pool });
|
|
46
44
|
}
|
|
47
|
-
await listenForNewCasinos({ signal, pool });
|
|
48
45
|
}
|
|
49
46
|
catch (e) {
|
|
50
47
|
logger.error(e, `Error initializing transfer processors`);
|
|
51
48
|
}
|
|
52
49
|
})();
|
|
53
50
|
}
|
|
54
|
-
async function listenForNewCasinos({ signal, pool, }) {
|
|
55
|
-
const pgClient = new pg.Client(config.SUPERUSER_DATABASE_URL);
|
|
56
|
-
await pgClient.connect();
|
|
57
|
-
const NewCasinoPayload = z.object({
|
|
58
|
-
id: z.string(),
|
|
59
|
-
});
|
|
60
|
-
pgClient.on("notification", async (msg) => {
|
|
61
|
-
logger.debug(msg, `[listenForNewCasinos] received notification`);
|
|
62
|
-
switch (msg.channel) {
|
|
63
|
-
case "hub:new_casino": {
|
|
64
|
-
if (!msg.payload) {
|
|
65
|
-
logger.error("hub:new_casino notification has no payload");
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
let json;
|
|
69
|
-
try {
|
|
70
|
-
json = JSON.parse(msg.payload);
|
|
71
|
-
}
|
|
72
|
-
catch (error) {
|
|
73
|
-
logger.error(error, "Error parsing new casino notification");
|
|
74
|
-
}
|
|
75
|
-
const result = NewCasinoPayload.safeParse(json);
|
|
76
|
-
if (!result.success) {
|
|
77
|
-
logger.error(result.error, "Error parsing new casino notification");
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
startCasinoTransferProcessor({
|
|
81
|
-
casinoId: result.data.id,
|
|
82
|
-
signal,
|
|
83
|
-
pool,
|
|
84
|
-
});
|
|
85
|
-
break;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
signal.addEventListener("abort", () => {
|
|
90
|
-
pgClient.removeAllListeners("notification");
|
|
91
|
-
pgClient.end();
|
|
92
|
-
});
|
|
93
|
-
await pgClient.query("LISTEN new_casino");
|
|
94
|
-
}
|