@moneypot/hub 1.21.0 → 1.22.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.
@@ -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 (!value) {
9
- console.warn("Missing NODE_ENV env var. Defaulting to 'development'");
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
  });
@@ -4,6 +4,14 @@ const HASH_HERALD_OPTIONS = {
4
4
  baseUrl: config.HASHCHAINSERVER_URL,
5
5
  applicationSecret: config.HASHCHAINSERVER_APPLICATION_SECRET,
6
6
  };
7
+ const HASH_HERALD_TIMEOUT_MS = 10_000;
8
+ function getHashWithTimeout(req) {
9
+ let timer;
10
+ const timeout = new Promise((_, reject) => {
11
+ timer = setTimeout(() => reject(new Error(`hash-herald did not answer within ${HASH_HERALD_TIMEOUT_MS}ms`)), HASH_HERALD_TIMEOUT_MS);
12
+ });
13
+ return Promise.race([getHash(HASH_HERALD_OPTIONS, req), timeout]).finally(() => clearTimeout(timer));
14
+ }
7
15
  function resultFromGetHashResponse(response) {
8
16
  if (!response.resp) {
9
17
  return { type: "bad_hash_chain", reason: "empty_response" };
@@ -26,7 +34,7 @@ export async function getIntermediateHash({ hashChainId, iteration, }) {
26
34
  iterations: iteration,
27
35
  context: undefined,
28
36
  };
29
- return getHash(HASH_HERALD_OPTIONS, req).then(resultFromGetHashResponse);
37
+ return getHashWithTimeout(req).then(resultFromGetHashResponse);
30
38
  }
31
39
  export async function getPreimageHash({ hashChainId, }) {
32
40
  const req = {
@@ -39,7 +47,7 @@ export async function getPreimageHash({ hashChainId, }) {
39
47
  },
40
48
  },
41
49
  };
42
- return getHash(HASH_HERALD_OPTIONS, req).then(resultFromGetHashResponse);
50
+ return getHashWithTimeout(req).then(resultFromGetHashResponse);
43
51
  }
44
52
  export async function getTerminalHash({ hashChainId, }) {
45
53
  const iterations = config.HASHCHAINSERVER_MAX_ITERATIONS;
@@ -53,5 +61,5 @@ export async function getTerminalHash({ hashChainId, }) {
53
61
  },
54
62
  },
55
63
  };
56
- return getHash(HASH_HERALD_OPTIONS, req).then(resultFromGetHashResponse);
64
+ return getHashWithTimeout(req).then(resultFromGetHashResponse);
57
65
  }
@@ -2,8 +2,10 @@ import * as pg from "pg";
2
2
  import { type PgClientInTransaction } from "../../db/transaction.js";
3
3
  import { LiabilityConfigInternal } from "../public/config.js";
4
4
  import { QueryExecutor } from "../../db/util.js";
5
- export declare function dbGetLiabilityIdsPastDeadline(pgClient: QueryExecutor, { limit }?: {
5
+ export declare function dbGetLiabilityIdsPastDeadline(pgClient: QueryExecutor, { limit, excludeIds, before, }?: {
6
6
  limit?: number;
7
+ excludeIds?: string[];
8
+ before?: Date;
7
9
  }): Promise<string[]>;
8
10
  export declare function startLiabilityDeadlineProcessor<RefType extends string>({ signal, pool, liabilityConfig, }: {
9
11
  signal: AbortSignal;
@@ -14,8 +16,11 @@ export declare function processOneDeadlinedLiability<RefType extends string>(pgC
14
16
  liabilityConfig: LiabilityConfigInternal<RefType>;
15
17
  liabilityId: string;
16
18
  }): Promise<void>;
17
- export declare function processDeadlinedLiabilities<RefType extends string>({ pool, liabilityConfig, signal, }: {
19
+ export declare function processDeadlinedLiabilities<RefType extends string>({ pool, liabilityConfig, signal, batchSize, }: {
18
20
  pool: pg.Pool;
19
21
  liabilityConfig: LiabilityConfigInternal<RefType>;
20
22
  signal: AbortSignal;
21
- }): Promise<void>;
23
+ batchSize?: number;
24
+ }): Promise<{
25
+ failed: number;
26
+ }>;
@@ -4,17 +4,18 @@ import { dbLockHouseBankroll, dbLockPlayerBalance } from "../../db/public.js";
4
4
  import { exactlyOneRow, maybeOneRow } from "../../db/util.js";
5
5
  import { abortableTimeout } from "../../util.js";
6
6
  const BATCH_SIZE = 100;
7
- export async function dbGetLiabilityIdsPastDeadline(pgClient, { limit = 100 } = {}) {
7
+ export async function dbGetLiabilityIdsPastDeadline(pgClient, { limit = 100, excludeIds = [], before = new Date(), } = {}) {
8
8
  const result = await pgClient.query({
9
9
  text: `
10
10
  SELECT id
11
11
  FROM hub.liability
12
12
  WHERE resolved_at IS NULL
13
- AND deadline < now()
13
+ AND deadline < $3
14
+ AND NOT (id = ANY($2::uuid[]))
14
15
  ORDER BY deadline ASC
15
16
  LIMIT $1
16
17
  `,
17
- values: [limit],
18
+ values: [limit, excludeIds, before],
18
19
  });
19
20
  return result.rows.map((r) => r.id);
20
21
  }
@@ -89,46 +90,55 @@ export async function processOneDeadlinedLiability(pgClient, { liabilityConfig,
89
90
  logger.error(`No house bankroll found for liability ${dbLockedLiability.id}.`);
90
91
  return;
91
92
  }
92
- try {
93
- await refTypeConfig.handleDeadline({
94
- pgClientInTransaction: pgClient,
95
- lockedLiability: dbLockedLiability,
96
- lockedPlayerBalance: dbLockedPlayerBalance,
97
- lockedHouseBankroll: dbLockedHouseBankroll,
98
- liabilityConfig: liabilityConfig,
99
- });
100
- const { resolved_at } = await pgClient
101
- .query(`SELECT resolved_at FROM hub.liability WHERE id = $1`, [dbLockedLiability.id])
102
- .then(exactlyOneRow);
103
- if (!resolved_at) {
104
- throw new Error(`Handler returned LIABILITY_RESOLVED but did not resolve liability ${dbLockedLiability.id} (ref_type: ${dbLockedLiability.ref_type}, ref_id: ${dbLockedLiability.ref_id}). ` +
105
- `Handler must call dbResolveLiabilityByRef before returning.`);
106
- }
107
- logger.debug(`Resolved liability ${dbLockedLiability.id}`);
108
- }
109
- catch (e) {
110
- logger.error(e, `Error handling deadline for liability ${dbLockedLiability.id}`);
111
- throw e;
112
- }
113
- }
114
- export async function processDeadlinedLiabilities({ pool, liabilityConfig, signal, }) {
115
- const liabilityIds = await dbGetLiabilityIdsPastDeadline(pool, {
116
- limit: BATCH_SIZE,
93
+ await refTypeConfig.handleDeadline({
94
+ pgClientInTransaction: pgClient,
95
+ lockedLiability: dbLockedLiability,
96
+ lockedPlayerBalance: dbLockedPlayerBalance,
97
+ lockedHouseBankroll: dbLockedHouseBankroll,
98
+ liabilityConfig: liabilityConfig,
117
99
  });
118
- if (liabilityIds.length === 0) {
119
- return;
100
+ const { resolved_at } = await pgClient
101
+ .query(`SELECT resolved_at FROM hub.liability WHERE id = $1`, [dbLockedLiability.id])
102
+ .then(exactlyOneRow);
103
+ if (!resolved_at) {
104
+ throw new Error(`Handler returned LIABILITY_RESOLVED but did not resolve liability ${dbLockedLiability.id} (ref_type: ${dbLockedLiability.ref_type}, ref_id: ${dbLockedLiability.ref_id}). ` +
105
+ `Handler must call dbResolveLiabilityByRef before returning.`);
120
106
  }
121
- logger.info(`Processor found ${liabilityIds.length} liability candidates...`);
122
- for (const liabilityId of liabilityIds) {
123
- if (signal.aborted) {
124
- logger.info(`[processExpiredLiabilities] Aborted by graceful shutdown.`);
107
+ logger.debug(`Resolved liability ${dbLockedLiability.id}`);
108
+ }
109
+ export async function processDeadlinedLiabilities({ pool, liabilityConfig, signal, batchSize = BATCH_SIZE, }) {
110
+ const before = new Date();
111
+ const tried = [];
112
+ let failed = 0;
113
+ while (!signal.aborted) {
114
+ const liabilityIds = await dbGetLiabilityIdsPastDeadline(pool, {
115
+ limit: batchSize,
116
+ excludeIds: tried,
117
+ before,
118
+ });
119
+ if (liabilityIds.length === 0) {
125
120
  break;
126
121
  }
127
- await withPgPoolTransaction(pool, async (pgClient) => {
128
- await processOneDeadlinedLiability(pgClient, {
129
- liabilityConfig,
130
- liabilityId,
131
- });
132
- });
122
+ logger.info(`Processor found ${liabilityIds.length} liability candidates...`);
123
+ for (const liabilityId of liabilityIds) {
124
+ if (signal.aborted) {
125
+ logger.info(`[processExpiredLiabilities] Aborted by graceful shutdown.`);
126
+ break;
127
+ }
128
+ tried.push(liabilityId);
129
+ try {
130
+ await withPgPoolTransaction(pool, async (pgClient) => {
131
+ await processOneDeadlinedLiability(pgClient, {
132
+ liabilityConfig,
133
+ liabilityId,
134
+ });
135
+ });
136
+ }
137
+ catch (e) {
138
+ failed++;
139
+ logger.error({ err: e, liabilityId }, `Error handling deadline for liability ${liabilityId}`);
140
+ }
141
+ }
133
142
  }
143
+ return { failed };
134
144
  }
@@ -0,0 +1,4 @@
1
+ import { type LoggerOptions } from "pino";
2
+ export declare function errorLogOptions(options: {
3
+ full: boolean;
4
+ }): Pick<LoggerOptions, "serializers" | "hooks">;
@@ -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
+ }
@@ -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,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
+ };
@@ -16,6 +16,7 @@ import { logger } from "../logger.js";
16
16
  import { ValidateCasinoFieldsPlugin } from "../plugins/validate-fields.js";
17
17
  import { HubAddCasinoPlugin } from "../plugins/hub-add-casino.js";
18
18
  import { HubAdjustBankrollPlugin } from "../plugins/hub-adjust-bankroll.js";
19
+ import { GraphQLErrorLogPlugin } from "../plugins/graphql-error-log.js";
19
20
  import { HubBalanceAlertPlugin } from "../plugins/hub-balance-alert.js";
20
21
  import { custom as customPgOmitArchivedPlugin } from "@graphile-contrib/pg-omit-archived";
21
22
  import { HubCurrentXPlugin } from "../plugins/hub-current-x.js";
@@ -46,6 +47,7 @@ export const requiredPlugins = [
46
47
  HubAddCasinoPlugin,
47
48
  ValidateCasinoFieldsPlugin,
48
49
  HubAdjustBankrollPlugin,
50
+ GraphQLErrorLogPlugin,
49
51
  HubOutcomeInputNonNullFieldsPlugin,
50
52
  HubBadHashChainErrorPlugin,
51
53
  HubCreateHashChainPlugin,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moneypot/hub",
3
- "version": "1.21.0",
3
+ "version": "1.22.1",
4
4
  "author": "moneypot.com",
5
5
  "homepage": "https://moneypot.com/hub",
6
6
  "keywords": [
@@ -87,7 +87,7 @@
87
87
  "codegen": "npx graphql-codegen",
88
88
  "codegen-watch": "graphql-codegen --watch",
89
89
  "build-dashboard": "cd ./dashboard && pnpm run build && rm -rf ../dist/dashboard && cp -Rv dist ../dist/dashboard",
90
- "test:startup": "dropdb --if-exists hub-test && createdb hub-test && SUPERUSER_DATABASE_URL=postgres://localhost:5432/hub-test DATABASE_URL=postgres://localhost:5432/hub-test tsx src/test-startup.ts",
90
+ "test:startup": "dropdb --if-exists hub-test && createdb hub-test && NODE_ENV=development SUPERUSER_DATABASE_URL=postgres://localhost:5432/hub-test DATABASE_URL=postgres://localhost:5432/hub-test tsx src/test-startup.ts",
91
91
  "test": "vitest run",
92
92
  "test:watch": "vitest",
93
93
  "test:bail": "pnpm test -- --bail --silent --reporter=basic 2>&1 | head -25"