@openora/core 0.4.1-canary.59 → 0.4.1-canary.62

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 (214) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/plugin.d.ts +2 -0
  3. package/dist/admin-console/plugin.d.ts.map +1 -1
  4. package/dist/analytics/plugin.d.ts +2 -0
  5. package/dist/analytics/plugin.d.ts.map +1 -1
  6. package/dist/audit/plugin.d.ts +2 -0
  7. package/dist/audit/plugin.d.ts.map +1 -1
  8. package/dist/audit/plugin.js +13 -0
  9. package/dist/audit/plugin.js.map +1 -1
  10. package/dist/casino/gaming/plugin.d.ts +2 -0
  11. package/dist/casino/gaming/plugin.d.ts.map +1 -1
  12. package/dist/casino/lobby/plugin.d.ts +2 -0
  13. package/dist/casino/lobby/plugin.d.ts.map +1 -1
  14. package/dist/cms/plugin.d.ts +2 -0
  15. package/dist/cms/plugin.d.ts.map +1 -1
  16. package/dist/compliance/plugin.d.ts +2 -0
  17. package/dist/compliance/plugin.d.ts.map +1 -1
  18. package/dist/contracts/adapters/audit.d.ts +1 -1
  19. package/dist/contracts/adapters/audit.d.ts.map +1 -1
  20. package/dist/contracts/adapters/audit.js.map +1 -1
  21. package/dist/contracts/adapters/gift-commands.d.ts +54 -0
  22. package/dist/contracts/adapters/gift-commands.d.ts.map +1 -0
  23. package/dist/contracts/adapters/gift-commands.js +11 -0
  24. package/dist/contracts/adapters/gift-commands.js.map +1 -0
  25. package/dist/contracts/adapters/index.d.ts +4 -0
  26. package/dist/contracts/adapters/index.d.ts.map +1 -1
  27. package/dist/contracts/adapters/index.js +2 -0
  28. package/dist/contracts/adapters/index.js.map +1 -1
  29. package/dist/contracts/adapters/rain-commands.d.ts +23 -0
  30. package/dist/contracts/adapters/rain-commands.d.ts.map +1 -0
  31. package/dist/contracts/adapters/rain-commands.js +12 -0
  32. package/dist/contracts/adapters/rain-commands.js.map +1 -0
  33. package/dist/contracts/schemas/chat-command-metadata.d.ts +12 -0
  34. package/dist/contracts/schemas/chat-command-metadata.d.ts.map +1 -1
  35. package/dist/contracts/schemas/chat-command-metadata.js +10 -0
  36. package/dist/contracts/schemas/chat-command-metadata.js.map +1 -1
  37. package/dist/contracts/schemas/chat-command.d.ts +25 -0
  38. package/dist/contracts/schemas/chat-command.d.ts.map +1 -1
  39. package/dist/contracts/schemas/chat-command.js +13 -1
  40. package/dist/contracts/schemas/chat-command.js.map +1 -1
  41. package/dist/contracts/schemas/events.d.ts +15 -3
  42. package/dist/contracts/schemas/events.d.ts.map +1 -1
  43. package/dist/contracts/schemas/events.js +17 -6
  44. package/dist/contracts/schemas/events.js.map +1 -1
  45. package/dist/contracts/schemas/identity.d.ts +2 -1
  46. package/dist/contracts/schemas/identity.d.ts.map +1 -1
  47. package/dist/contracts/schemas/identity.js +5 -1
  48. package/dist/contracts/schemas/identity.js.map +1 -1
  49. package/dist/engagement/chat/__tests__/chat.service.int.test.js +211 -8
  50. package/dist/engagement/chat/__tests__/chat.service.int.test.js.map +1 -1
  51. package/dist/engagement/chat/contract/index.d.ts +154 -8
  52. package/dist/engagement/chat/contract/index.d.ts.map +1 -1
  53. package/dist/engagement/chat/contract/index.js +44 -2
  54. package/dist/engagement/chat/contract/index.js.map +1 -1
  55. package/dist/engagement/chat/drizzle/migrations/0004_panoramic_robin_chapel.sql +6 -0
  56. package/dist/engagement/chat/drizzle/migrations/meta/0004_snapshot.json +666 -0
  57. package/dist/engagement/chat/drizzle/migrations/meta/_journal.json +7 -0
  58. package/dist/engagement/chat/plugin.d.ts +2 -0
  59. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  60. package/dist/engagement/chat/router/index.d.ts +126 -8
  61. package/dist/engagement/chat/router/index.d.ts.map +1 -1
  62. package/dist/engagement/chat/router/index.js +10 -2
  63. package/dist/engagement/chat/router/index.js.map +1 -1
  64. package/dist/engagement/chat/schema/index.d.ts +50 -0
  65. package/dist/engagement/chat/schema/index.d.ts.map +1 -1
  66. package/dist/engagement/chat/schema/index.js +15 -3
  67. package/dist/engagement/chat/schema/index.js.map +1 -1
  68. package/dist/engagement/chat/service/chat.service.d.ts +95 -9
  69. package/dist/engagement/chat/service/chat.service.d.ts.map +1 -1
  70. package/dist/engagement/chat/service/chat.service.js +115 -24
  71. package/dist/engagement/chat/service/chat.service.js.map +1 -1
  72. package/dist/engagement/chat-commands/contract/index.d.ts +143 -71
  73. package/dist/engagement/chat-commands/contract/index.d.ts.map +1 -1
  74. package/dist/engagement/chat-commands/contract/index.js +48 -70
  75. package/dist/engagement/chat-commands/contract/index.js.map +1 -1
  76. package/dist/engagement/chat-commands/plugin.d.ts +3 -1
  77. package/dist/engagement/chat-commands/plugin.d.ts.map +1 -1
  78. package/dist/engagement/chat-commands/plugin.js +4 -4
  79. package/dist/engagement/chat-commands/plugin.js.map +1 -1
  80. package/dist/engagement/chat-commands/router/index.d.ts +149 -83
  81. package/dist/engagement/chat-commands/router/index.d.ts.map +1 -1
  82. package/dist/engagement/chat-commands/router/index.js +36 -26
  83. package/dist/engagement/chat-commands/router/index.js.map +1 -1
  84. package/dist/engagement/chat-commands/seed/index.d.ts.map +1 -1
  85. package/dist/engagement/chat-commands/seed/index.js +12 -0
  86. package/dist/engagement/chat-commands/seed/index.js.map +1 -1
  87. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.js +197 -733
  88. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.js.map +1 -1
  89. package/dist/engagement/chat-commands/service/chat-commands.service.d.ts +43 -68
  90. package/dist/engagement/chat-commands/service/chat-commands.service.d.ts.map +1 -1
  91. package/dist/engagement/chat-commands/service/chat-commands.service.js +138 -571
  92. package/dist/engagement/chat-commands/service/chat-commands.service.js.map +1 -1
  93. package/dist/engagement/contracts.d.ts +1 -0
  94. package/dist/engagement/contracts.d.ts.map +1 -1
  95. package/dist/engagement/contracts.js +1 -0
  96. package/dist/engagement/contracts.js.map +1 -1
  97. package/dist/engagement/index.d.ts +1 -0
  98. package/dist/engagement/index.d.ts.map +1 -1
  99. package/dist/engagement/index.js +1 -0
  100. package/dist/engagement/index.js.map +1 -1
  101. package/dist/engagement/notifications/plugin.d.ts +2 -0
  102. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  103. package/dist/engagement/server.d.ts +1 -0
  104. package/dist/engagement/server.d.ts.map +1 -1
  105. package/dist/engagement/server.js +1 -0
  106. package/dist/engagement/server.js.map +1 -1
  107. package/dist/engagement/social-transfers/contract/index.d.ts +67 -0
  108. package/dist/engagement/social-transfers/contract/index.d.ts.map +1 -0
  109. package/dist/engagement/social-transfers/contract/index.js +21 -0
  110. package/dist/engagement/social-transfers/contract/index.js.map +1 -0
  111. package/dist/engagement/social-transfers/drizzle/migrations/0000_clammy_rattler.sql +46 -0
  112. package/dist/engagement/social-transfers/drizzle/migrations/meta/0000_snapshot.json +287 -0
  113. package/dist/engagement/social-transfers/drizzle/migrations/meta/_journal.json +13 -0
  114. package/dist/engagement/social-transfers/drizzle.config.d.ts +4 -0
  115. package/dist/engagement/social-transfers/drizzle.config.d.ts.map +1 -0
  116. package/dist/engagement/social-transfers/drizzle.config.js +21 -0
  117. package/dist/engagement/social-transfers/drizzle.config.js.map +1 -0
  118. package/dist/engagement/social-transfers/index.d.ts +3 -0
  119. package/dist/engagement/social-transfers/index.d.ts.map +1 -0
  120. package/dist/engagement/social-transfers/index.js +6 -0
  121. package/dist/engagement/social-transfers/index.js.map +1 -0
  122. package/dist/engagement/social-transfers/migrate.d.ts +5 -0
  123. package/dist/engagement/social-transfers/migrate.d.ts.map +1 -0
  124. package/dist/engagement/social-transfers/migrate.js +17 -0
  125. package/dist/engagement/social-transfers/migrate.js.map +1 -0
  126. package/dist/engagement/social-transfers/plugin.d.ts +111 -0
  127. package/dist/engagement/social-transfers/plugin.d.ts.map +1 -0
  128. package/dist/engagement/social-transfers/plugin.js +24 -0
  129. package/dist/engagement/social-transfers/plugin.js.map +1 -0
  130. package/dist/engagement/social-transfers/router/index.d.ts +59 -0
  131. package/dist/engagement/social-transfers/router/index.d.ts.map +1 -0
  132. package/dist/engagement/social-transfers/router/index.js +25 -0
  133. package/dist/engagement/social-transfers/router/index.js.map +1 -0
  134. package/dist/engagement/social-transfers/schema/index.d.ts +609 -0
  135. package/dist/engagement/social-transfers/schema/index.d.ts.map +1 -0
  136. package/dist/engagement/social-transfers/schema/index.js +61 -0
  137. package/dist/engagement/social-transfers/schema/index.js.map +1 -0
  138. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.d.ts +2 -0
  139. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.d.ts.map +1 -0
  140. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.js +672 -0
  141. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.js.map +1 -0
  142. package/dist/engagement/social-transfers/service/social-transfers.service.d.ts +72 -0
  143. package/dist/engagement/social-transfers/service/social-transfers.service.d.ts.map +1 -0
  144. package/dist/engagement/social-transfers/service/social-transfers.service.js +726 -0
  145. package/dist/engagement/social-transfers/service/social-transfers.service.js.map +1 -0
  146. package/dist/iam/plugin.d.ts +2 -0
  147. package/dist/iam/plugin.d.ts.map +1 -1
  148. package/dist/iam/service/iam.service.d.ts +1 -1
  149. package/dist/pam/identity/__tests__/identity.service.int.test.js +47 -2
  150. package/dist/pam/identity/__tests__/identity.service.int.test.js.map +1 -1
  151. package/dist/pam/identity/__tests__/phone-login.service.int.test.js +23 -2
  152. package/dist/pam/identity/__tests__/phone-login.service.int.test.js.map +1 -1
  153. package/dist/pam/identity/plugin.d.ts +2 -0
  154. package/dist/pam/identity/plugin.d.ts.map +1 -1
  155. package/dist/pam/identity/service/identity.service.d.ts.map +1 -1
  156. package/dist/pam/identity/service/identity.service.js +29 -1
  157. package/dist/pam/identity/service/identity.service.js.map +1 -1
  158. package/dist/pam/identity/service/phone-login.service.d.ts.map +1 -1
  159. package/dist/pam/identity/service/phone-login.service.js +23 -0
  160. package/dist/pam/identity/service/phone-login.service.js.map +1 -1
  161. package/dist/pam/player-management/__tests__/player.router.int.test.js +27 -4
  162. package/dist/pam/player-management/__tests__/player.router.int.test.js.map +1 -1
  163. package/dist/pam/player-management/__tests__/player.service.int.test.js +159 -6
  164. package/dist/pam/player-management/__tests__/player.service.int.test.js.map +1 -1
  165. package/dist/pam/player-management/contract/index.d.ts +39 -0
  166. package/dist/pam/player-management/contract/index.d.ts.map +1 -1
  167. package/dist/pam/player-management/contract/index.js +33 -1
  168. package/dist/pam/player-management/contract/index.js.map +1 -1
  169. package/dist/pam/player-management/plugin.d.ts +3 -1
  170. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  171. package/dist/pam/player-management/plugin.js +3 -3
  172. package/dist/pam/player-management/plugin.js.map +1 -1
  173. package/dist/pam/player-management/router/index.d.ts +21 -0
  174. package/dist/pam/player-management/router/index.d.ts.map +1 -1
  175. package/dist/pam/player-management/router/index.js +12 -2
  176. package/dist/pam/player-management/router/index.js.map +1 -1
  177. package/dist/pam/player-management/service/player.service.d.ts +10 -3
  178. package/dist/pam/player-management/service/player.service.d.ts.map +1 -1
  179. package/dist/pam/player-management/service/player.service.js +66 -3
  180. package/dist/pam/player-management/service/player.service.js.map +1 -1
  181. package/dist/pam/player-note/__tests__/player-note.router.int.test.d.ts +2 -0
  182. package/dist/pam/player-note/__tests__/player-note.router.int.test.d.ts.map +1 -0
  183. package/dist/pam/player-note/__tests__/player-note.router.int.test.js +52 -0
  184. package/dist/pam/player-note/__tests__/player-note.router.int.test.js.map +1 -0
  185. package/dist/pam/player-note/plugin.d.ts +49 -46
  186. package/dist/pam/player-note/plugin.d.ts.map +1 -1
  187. package/dist/pam/player-note/plugin.js +3 -1
  188. package/dist/pam/player-note/plugin.js.map +1 -1
  189. package/dist/pam/player-note/router/index.d.ts +2 -1
  190. package/dist/pam/player-note/router/index.d.ts.map +1 -1
  191. package/dist/pam/player-note/router/index.js +13 -2
  192. package/dist/pam/player-note/router/index.js.map +1 -1
  193. package/dist/pam/profile/plugin.d.ts +2 -0
  194. package/dist/pam/profile/plugin.d.ts.map +1 -1
  195. package/dist/pam/tag/plugin.d.ts +2 -0
  196. package/dist/pam/tag/plugin.d.ts.map +1 -1
  197. package/dist/server/auth/permissions.d.ts +24 -0
  198. package/dist/server/auth/permissions.d.ts.map +1 -1
  199. package/dist/server/auth/permissions.js +4 -0
  200. package/dist/server/auth/permissions.js.map +1 -1
  201. package/dist/server/runtime/core-token-catalog.d.ts +2 -0
  202. package/dist/server/runtime/core-token-catalog.d.ts.map +1 -1
  203. package/dist/server/runtime/core-token-catalog.js +3 -1
  204. package/dist/server/runtime/core-token-catalog.js.map +1 -1
  205. package/dist/wallet/__tests__/wallet.service.int.test.js +7 -0
  206. package/dist/wallet/__tests__/wallet.service.int.test.js.map +1 -1
  207. package/dist/wallet/plugin.d.ts +2 -0
  208. package/dist/wallet/plugin.d.ts.map +1 -1
  209. package/dist/wallet/router/index.js +1 -1
  210. package/dist/wallet/router/index.js.map +1 -1
  211. package/dist/wallet/service/wallet.service.d.ts.map +1 -1
  212. package/dist/wallet/service/wallet.service.js +16 -0
  213. package/dist/wallet/service/wallet.service.js.map +1 -1
  214. package/package.json +21 -1
@@ -0,0 +1,726 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { and, eq, isNull, sql } from 'drizzle-orm';
3
+ import { makeNotFoundError, makeConflictError, mapConcurrent, moneyToNumber, findOneOrThrow, createDomainError, } from '@openora/core/server';
4
+ import { chatChannel } from '@openora/core/contracts';
5
+ import { chatCommandConfig } from '@openora/core/engagement/schema/chat-commands';
6
+ import { playerGift, playerDonate, playerRain, playerRainReceiver } from '../schema/index.js';
7
+ export const CommandDisabledError = makeNotFoundError('ChatCommand');
8
+ export const NoOnlineUsersError = makeConflictError('NoOnlineUsers', 'No other users are online in this room');
9
+ export const InsufficientBalanceError = makeConflictError('InsufficientBalance', 'Not enough balance');
10
+ export const ExceedsLimitError = makeConflictError('ExceedsLimit', 'Amount exceeds the command limit');
11
+ export const BelowMinimumError = makeConflictError('BelowMinimum', 'Amount is below the minimum for this command');
12
+ export const RainCreditError = makeConflictError('RainCreditError', 'A recipient wallet is unavailable; rain aborted');
13
+ export const ChatPlayerNotFoundError = makeNotFoundError('ChatPlayer');
14
+ export const GiftNotFoundError = makeNotFoundError('ChatGift');
15
+ export const GiftAlreadyClaimedError = makeConflictError('GiftAlreadyClaimed', 'This gift has already been claimed');
16
+ export const GiftSelfClaimError = makeConflictError('GiftSelfClaim', 'You cannot claim your own gift');
17
+ export const GiftCreditError = makeConflictError('GiftCreditError', 'Recipient wallet is unavailable; gift claim aborted');
18
+ export const DonateSelfError = makeConflictError('DonateSelf', 'You cannot donate to yourself');
19
+ export const TooManyRecipientsError = makeConflictError('TooManyRecipients', 'Amount too small: you need at least $1 per recipient');
20
+ export const ChatCommandIdempotencyKeyReuseError = makeConflictError('ChatCommandIdempotencyKeyReuse', 'This idempotency key was already used with different request parameters');
21
+ export const ConcurrentCommandReplayError = makeConflictError('ConcurrentCommandReplay', 'This request is already being processed - please retry');
22
+ export const ChatRoomNotMemberError = createDomainError('ChatRoomNotMemberError', (roomId) => `You are not a member of room: ${roomId}`);
23
+ // Internal-only: createDomainError instances don't expose their constructor args as
24
+ // properties, so this carries the gift's roomId across the doClaimGift -> toClaimGiftResult
25
+ // boundary - without it, the ClaimGiftResult's room_not_member reason has no roomId, and
26
+ // chat-commands (which only has giftId in scope) would build a ChatRoomNotMemberError with
27
+ // the wrong id. Never crosses the GIFT_COMMANDS port boundary itself.
28
+ class GiftRoomAccessError extends Error {
29
+ roomId;
30
+ constructor(roomId) {
31
+ super('room_not_member');
32
+ this.roomId = roomId;
33
+ this.name = 'GiftRoomAccessError';
34
+ }
35
+ }
36
+ const COMMAND_IDEMPOTENCY_TTL_MS = 5 * 60 * 1000;
37
+ // The replay guard must match on the COMPLETE request, not just the amount - a reused
38
+ // key with a different room, recipient count, or donate target is a distinct request,
39
+ // not a replay of the original. `idempotencyKey` itself is excluded so the fingerprint
40
+ // is stable for the row it guards. The cache-key namespace stays `chat-command:*` even
41
+ // though this logic now lives here - see AGENTS.md.
42
+ export function fingerprintCommand(input) {
43
+ const canonical = input.type === 'gift'
44
+ ? { type: input.type, amount: input.amount, roomId: input.roomId }
45
+ : input.type === 'rain'
46
+ ? {
47
+ type: input.type,
48
+ amount: input.amount,
49
+ recipientCount: input.recipientCount,
50
+ roomId: input.roomId,
51
+ }
52
+ : {
53
+ type: input.type,
54
+ amount: input.amount,
55
+ targetUsername: input.targetUsername,
56
+ roomId: input.roomId,
57
+ };
58
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
59
+ }
60
+ function shuffleArray(arr) {
61
+ const result = [...arr];
62
+ for (let i = result.length - 1; i > 0; i--) {
63
+ const j = Math.floor(Math.random() * (i + 1));
64
+ const a = result.at(i);
65
+ const b = result.at(j);
66
+ if (a !== undefined && b !== undefined) {
67
+ result[i] = b;
68
+ result[j] = a;
69
+ }
70
+ }
71
+ return result;
72
+ }
73
+ // Translates a thrown domain error into the GIFT_COMMANDS port's discriminated
74
+ // result so chat-commands never needs to import this module's error classes
75
+ // (module boundary) - only genuinely unexpected errors still throw across the
76
+ // port. `.name`-based fallback covers an error surfacing from CHAT_ROOM_ACCESS
77
+ // (a different module's class), mirroring the sender-side room-access guard.
78
+ function toSendGiftResult(error) {
79
+ if (error instanceof CommandDisabledError) {
80
+ return { ok: false, reason: 'disabled' };
81
+ }
82
+ if (error instanceof InsufficientBalanceError) {
83
+ return { ok: false, reason: 'insufficient_balance' };
84
+ }
85
+ if (error instanceof ExceedsLimitError) {
86
+ return { ok: false, reason: 'exceeds_limit' };
87
+ }
88
+ if (error instanceof BelowMinimumError) {
89
+ return { ok: false, reason: 'below_minimum' };
90
+ }
91
+ if (error instanceof ChatCommandIdempotencyKeyReuseError) {
92
+ return { ok: false, reason: 'idempotency_key_reuse' };
93
+ }
94
+ if (error instanceof ConcurrentCommandReplayError) {
95
+ return { ok: false, reason: 'concurrent_replay' };
96
+ }
97
+ if (error instanceof ChatRoomNotMemberError ||
98
+ (error instanceof Error && error.name === 'ChatRoomNotMemberError')) {
99
+ return { ok: false, reason: 'room_not_member' };
100
+ }
101
+ throw error;
102
+ }
103
+ function toClaimGiftResult(error) {
104
+ if (error instanceof GiftNotFoundError) {
105
+ return { ok: false, reason: 'gift_not_found' };
106
+ }
107
+ if (error instanceof GiftAlreadyClaimedError) {
108
+ return { ok: false, reason: 'already_claimed' };
109
+ }
110
+ if (error instanceof GiftSelfClaimError) {
111
+ return { ok: false, reason: 'self_claim' };
112
+ }
113
+ if (error instanceof GiftRoomAccessError) {
114
+ return { ok: false, reason: 'room_not_member', roomId: error.roomId };
115
+ }
116
+ if (error instanceof GiftCreditError) {
117
+ return { ok: false, reason: 'gift_credit_failed' };
118
+ }
119
+ throw error;
120
+ }
121
+ function toGetGiftResult(error) {
122
+ if (error instanceof GiftNotFoundError) {
123
+ return { ok: false, reason: 'gift_not_found' };
124
+ }
125
+ if (error instanceof GiftRoomAccessError) {
126
+ return { ok: false, reason: 'room_not_member', roomId: error.roomId };
127
+ }
128
+ throw error;
129
+ }
130
+ function toSendRainResult(error) {
131
+ if (error instanceof CommandDisabledError) {
132
+ return { ok: false, reason: 'disabled' };
133
+ }
134
+ if (error instanceof InsufficientBalanceError) {
135
+ return { ok: false, reason: 'insufficient_balance' };
136
+ }
137
+ if (error instanceof ExceedsLimitError) {
138
+ return { ok: false, reason: 'exceeds_limit' };
139
+ }
140
+ if (error instanceof BelowMinimumError) {
141
+ return { ok: false, reason: 'below_minimum' };
142
+ }
143
+ if (error instanceof NoOnlineUsersError) {
144
+ return { ok: false, reason: 'no_online_users' };
145
+ }
146
+ if (error instanceof TooManyRecipientsError) {
147
+ return { ok: false, reason: 'too_many_recipients' };
148
+ }
149
+ if (error instanceof RainCreditError) {
150
+ return { ok: false, reason: 'rain_credit_failed' };
151
+ }
152
+ if (error instanceof ChatCommandIdempotencyKeyReuseError) {
153
+ return { ok: false, reason: 'idempotency_key_reuse' };
154
+ }
155
+ if (error instanceof ConcurrentCommandReplayError) {
156
+ return { ok: false, reason: 'concurrent_replay' };
157
+ }
158
+ if (error instanceof ChatRoomNotMemberError ||
159
+ (error instanceof Error && error.name === 'ChatRoomNotMemberError')) {
160
+ return { ok: false, reason: 'room_not_member' };
161
+ }
162
+ throw error;
163
+ }
164
+ export class SocialTransfersService {
165
+ drizzle;
166
+ systemWriter;
167
+ wallet;
168
+ directory;
169
+ audit;
170
+ transport;
171
+ events;
172
+ roomAccess;
173
+ cache;
174
+ idempotencyTtlMs;
175
+ constructor(drizzle, systemWriter, wallet, directory, audit, transport, events, roomAccess, cache, idempotencyTtlMs = COMMAND_IDEMPOTENCY_TTL_MS) {
176
+ this.drizzle = drizzle;
177
+ this.systemWriter = systemWriter;
178
+ this.wallet = wallet;
179
+ this.directory = directory;
180
+ this.audit = audit;
181
+ this.transport = transport;
182
+ this.events = events;
183
+ this.roomAccess = roomAccess;
184
+ this.cache = cache;
185
+ this.idempotencyTtlMs = idempotencyTtlMs;
186
+ }
187
+ // GIFT_COMMANDS port impl, consumed by chat-commands via DI - never throws,
188
+ // every failure the caller must distinguish comes back as a typed `reason`.
189
+ async sendGift(input, actorId) {
190
+ try {
191
+ const message = await this.doSendGift(input, actorId);
192
+ return { ok: true, message };
193
+ }
194
+ catch (error) {
195
+ return toSendGiftResult(error);
196
+ }
197
+ }
198
+ async claimGift(giftId, claimerId) {
199
+ try {
200
+ return await this.doClaimGift(giftId, claimerId);
201
+ }
202
+ catch (error) {
203
+ return toClaimGiftResult(error);
204
+ }
205
+ }
206
+ async getGift(giftId, viewerId) {
207
+ try {
208
+ const gift = await this.doGetGift(giftId, viewerId);
209
+ return { ok: true, gift };
210
+ }
211
+ catch (error) {
212
+ return toGetGiftResult(error);
213
+ }
214
+ }
215
+ // RAIN_COMMANDS port impl, consumed by chat-commands via DI - never throws.
216
+ // `input.onlineUserIds` is resolved by the caller (chat-commands owns
217
+ // presence via its own dependency on `chat`); this module never queries
218
+ // online status itself.
219
+ async sendRain(input, actorId) {
220
+ try {
221
+ const message = await this.doSendRain(input, actorId);
222
+ return { ok: true, message };
223
+ }
224
+ catch (error) {
225
+ return toSendRainResult(error);
226
+ }
227
+ }
228
+ async sendDonate(input, actorId) {
229
+ return this.doSendDonate(input, actorId);
230
+ }
231
+ async loadCommandConfig(key) {
232
+ const [row] = await this.drizzle.db
233
+ .select()
234
+ .from(chatCommandConfig)
235
+ .where(eq(chatCommandConfig.key, key))
236
+ .limit(1);
237
+ if (!row || !row.enabled) {
238
+ throw new CommandDisabledError(key);
239
+ }
240
+ return row.config ?? null;
241
+ }
242
+ async verifyRoomAccessIfNeeded(roomId, actorId) {
243
+ if (!roomId) {
244
+ return;
245
+ }
246
+ try {
247
+ await this.roomAccess.verifyRoomAccess(roomId, actorId);
248
+ }
249
+ catch (error) {
250
+ if (error instanceof Error && error.name === 'ChatRoomNotMemberError') {
251
+ throw new ChatRoomNotMemberError(roomId);
252
+ }
253
+ throw error;
254
+ }
255
+ }
256
+ async findCommandReplay(commandType, actorId, idempotencyKey, fingerprint) {
257
+ const record = await this.cache.get(this.idempotencyCacheKey(commandType, actorId, idempotencyKey));
258
+ if (!record) {
259
+ return null;
260
+ }
261
+ if (record.fingerprint !== fingerprint) {
262
+ throw new ChatCommandIdempotencyKeyReuseError();
263
+ }
264
+ if (!record.result) {
265
+ throw new ConcurrentCommandReplayError();
266
+ }
267
+ return record.result;
268
+ }
269
+ idempotencyCacheKey(commandType, actorId, idempotencyKey) {
270
+ return `chat-command:idempotency:${actorId}:${commandType}:${idempotencyKey}`;
271
+ }
272
+ async reserveCommandIdempotency(commandType, actorId, idempotencyKey, fingerprint) {
273
+ const reserved = await this.cache.setIfAbsent(this.idempotencyCacheKey(commandType, actorId, idempotencyKey), { fingerprint, result: null }, { ttlMs: this.idempotencyTtlMs });
274
+ if (!reserved) {
275
+ throw new ConcurrentCommandReplayError();
276
+ }
277
+ }
278
+ async completeCommandIdempotency(commandType, actorId, idempotencyKey, fingerprint, result) {
279
+ await this.cache.set(this.idempotencyCacheKey(commandType, actorId, idempotencyKey), { fingerprint, result }, { ttlMs: this.idempotencyTtlMs });
280
+ }
281
+ async runWithCommandReservation(commandType, actorId, idempotencyKey, work) {
282
+ try {
283
+ return await work();
284
+ }
285
+ catch (error) {
286
+ await this.cache.delete(this.idempotencyCacheKey(commandType, actorId, idempotencyKey));
287
+ throw error;
288
+ }
289
+ }
290
+ // Exact, case-insensitive username resolution for callers holding a complete, already-known
291
+ // username (never a partial search term) - `/donate`. Distinct from findPlayerIds' capped
292
+ // fuzzy substring search, which can silently drop the real match once more than 20 unrelated
293
+ // accounts substring-collide with a short/common username.
294
+ async resolveExactPlayer(username) {
295
+ const summary = await this.directory.getPlayerByUsername(username);
296
+ if (!summary) {
297
+ throw new ChatPlayerNotFoundError(username);
298
+ }
299
+ return summary;
300
+ }
301
+ async doSendGift(input, actorId) {
302
+ await this.verifyRoomAccessIfNeeded(input.roomId, actorId);
303
+ const config = await this.loadCommandConfig('gift');
304
+ if (config?.maxAmount !== undefined &&
305
+ moneyToNumber(input.amount) > moneyToNumber(config.maxAmount)) {
306
+ throw new ExceedsLimitError();
307
+ }
308
+ if (config?.minAmount !== undefined &&
309
+ moneyToNumber(input.amount) < moneyToNumber(config.minAmount)) {
310
+ throw new BelowMinimumError();
311
+ }
312
+ const senderSummaries = await this.directory.lookupPlayers([actorId]);
313
+ const senderSummary = senderSummaries.find((s) => s.userId === actorId);
314
+ if (!senderSummary) {
315
+ throw new ChatPlayerNotFoundError(actorId);
316
+ }
317
+ const senderUsername = senderSummary.username;
318
+ // Sender resolved BEFORE reserving (mirrors doSendDonate): runWithCommandReservation
319
+ // only releases the reservation on a failure inside its own callback, so anything
320
+ // between reserveCommandIdempotency and that callback would otherwise leak a stuck
321
+ // reservation for the full TTL on a transient directory error.
322
+ const fingerprint = fingerprintCommand({ type: 'gift', ...input });
323
+ const replay = await this.findCommandReplay('gift', actorId, input.idempotencyKey, fingerprint);
324
+ if (replay) {
325
+ return replay;
326
+ }
327
+ await this.reserveCommandIdempotency('gift', actorId, input.idempotencyKey, fingerprint);
328
+ const { msg, giftId, currency } = await this.runWithCommandReservation('gift', actorId, input.idempotencyKey, () => this.drizzle.db.transaction(async (tx) => {
329
+ const debit = await this.wallet.debit(tx, {
330
+ userId: actorId,
331
+ amount: input.amount,
332
+ type: 'gift',
333
+ });
334
+ if (!debit.ok) {
335
+ throw new InsufficientBalanceError();
336
+ }
337
+ const [giftRow] = await tx
338
+ .insert(playerGift)
339
+ .values({
340
+ senderId: actorId,
341
+ senderUsername,
342
+ amount: input.amount,
343
+ currency: debit.currency,
344
+ roomId: input.roomId,
345
+ // messageId is back-filled below, in the same transaction, once the
346
+ // system message exists (same insert-then-update pattern as before).
347
+ messageId: '00000000-0000-0000-0000-000000000000',
348
+ })
349
+ .returning();
350
+ if (!giftRow) {
351
+ throw new InsufficientBalanceError();
352
+ }
353
+ const systemMsg = await this.systemWriter.postSystemMessage({
354
+ roomId: input.roomId,
355
+ actorId,
356
+ tx,
357
+ metadata: {
358
+ command: 'gift',
359
+ giftId: giftRow.id,
360
+ senderId: actorId,
361
+ senderUsername,
362
+ amount: input.amount,
363
+ currency: debit.currency,
364
+ },
365
+ });
366
+ await tx
367
+ .update(playerGift)
368
+ .set({ messageId: systemMsg.id })
369
+ .where(eq(playerGift.id, giftRow.id));
370
+ await this.audit.recordInTransaction(tx, {
371
+ actorId,
372
+ actorType: 'player',
373
+ action: 'chat.gift',
374
+ resourceType: 'player_gift',
375
+ resourceId: giftRow.id,
376
+ before: null,
377
+ after: { amount: input.amount, roomId: input.roomId },
378
+ });
379
+ return { msg: systemMsg, giftId: giftRow.id, currency: debit.currency };
380
+ }));
381
+ await this.completeCommandIdempotency('gift', actorId, input.idempotencyKey, fingerprint, msg);
382
+ // The caller now owns the commit boundary: postSystemMessage was passed `tx` above so
383
+ // it did not auto-publish - publish only now that this transaction has committed.
384
+ void this.transport.publish(chatChannel(input.roomId), msg);
385
+ void this.events.emit('chat.gift.sent', {
386
+ giftId,
387
+ senderId: actorId,
388
+ senderUsername,
389
+ amount: input.amount,
390
+ currency,
391
+ roomId: input.roomId,
392
+ messageId: msg.id,
393
+ });
394
+ return msg;
395
+ }
396
+ async doClaimGift(giftId, claimerId) {
397
+ const claimerSummaries = await this.directory.lookupPlayers([claimerId]);
398
+ const claimerSummary = claimerSummaries.find((s) => s.userId === claimerId);
399
+ if (!claimerSummary) {
400
+ throw new GiftNotFoundError(claimerId);
401
+ }
402
+ const claimerUsername = claimerSummary.username;
403
+ const { claimed, currency, roomId, senderId } = await this.drizzle.db.transaction(async (tx) => {
404
+ // FOR UPDATE serializes concurrent claims against the same gift row under READ COMMITTED.
405
+ const giftRow = findOneOrThrow(await tx.select().from(playerGift).where(eq(playerGift.id, giftId)).for('update'), new GiftNotFoundError(giftId));
406
+ try {
407
+ await this.verifyRoomAccessIfNeeded(giftRow.roomId, claimerId);
408
+ }
409
+ catch (error) {
410
+ if (error instanceof Error && error.name === 'ChatRoomNotMemberError') {
411
+ throw new GiftRoomAccessError(giftRow.roomId);
412
+ }
413
+ throw error;
414
+ }
415
+ if (giftRow.senderId === claimerId) {
416
+ throw new GiftSelfClaimError();
417
+ }
418
+ if (giftRow.claimedBy) {
419
+ throw new GiftAlreadyClaimedError();
420
+ }
421
+ const claimedAt = new Date();
422
+ // Guarded conditional update: WHERE claimed_by IS NULL stays as defense-in-depth
423
+ // alongside the row lock above (same pattern as wallet's guarded debit).
424
+ const results = await tx
425
+ .update(playerGift)
426
+ .set({ claimedBy: claimerId, claimedByUsername: claimerUsername, claimedAt })
427
+ .where(and(eq(playerGift.id, giftId), isNull(playerGift.claimedBy)))
428
+ .returning();
429
+ const updated = findOneOrThrow(results, new GiftAlreadyClaimedError());
430
+ const credit = await this.wallet.credit(tx, {
431
+ userId: claimerId,
432
+ amount: updated.amount,
433
+ currency: updated.currency,
434
+ type: 'gift',
435
+ });
436
+ if (!credit.ok) {
437
+ throw new GiftCreditError();
438
+ }
439
+ await this.audit.recordInTransaction(tx, {
440
+ actorId: claimerId,
441
+ actorType: 'player',
442
+ action: 'chat.gift.claimed',
443
+ resourceType: 'player_gift',
444
+ resourceId: giftId,
445
+ before: null,
446
+ after: { claimedBy: claimerId, amount: updated.amount },
447
+ });
448
+ return {
449
+ claimed: updated,
450
+ currency: updated.currency,
451
+ roomId: updated.roomId,
452
+ senderId: giftRow.senderId,
453
+ };
454
+ });
455
+ const claimedAtIso = claimed.claimedAt?.toISOString() ?? new Date().toISOString();
456
+ void this.transport.publish(chatChannel(roomId), {
457
+ event: 'gift.claimed',
458
+ giftId,
459
+ claimedBy: claimerId,
460
+ claimedByUsername: claimerUsername,
461
+ claimedAt: claimedAtIso,
462
+ });
463
+ void this.events.emit('chat.gift.claimed', {
464
+ giftId,
465
+ claimerId,
466
+ claimerUsername,
467
+ senderId,
468
+ amount: claimed.amount,
469
+ currency,
470
+ roomId,
471
+ });
472
+ return {
473
+ ok: true,
474
+ claimedBy: claimerId,
475
+ claimedByUsername: claimerUsername,
476
+ claimedAt: claimedAtIso,
477
+ };
478
+ }
479
+ async doGetGift(giftId, viewerId) {
480
+ const rows = await this.drizzle.db.select().from(playerGift).where(eq(playerGift.id, giftId));
481
+ const giftRow = findOneOrThrow(rows, new GiftNotFoundError(giftId));
482
+ // Same room-membership boundary as doClaimGift: a viewer must be a member
483
+ // of the gift's room to poll its status. No FOR UPDATE - read-only, no
484
+ // mutation, no need to serialize against concurrent claims.
485
+ try {
486
+ await this.verifyRoomAccessIfNeeded(giftRow.roomId, viewerId);
487
+ }
488
+ catch (error) {
489
+ if (error instanceof Error && error.name === 'ChatRoomNotMemberError') {
490
+ throw new GiftRoomAccessError(giftRow.roomId);
491
+ }
492
+ throw error;
493
+ }
494
+ return {
495
+ id: giftRow.id,
496
+ senderId: giftRow.senderId,
497
+ senderUsername: giftRow.senderUsername,
498
+ amount: giftRow.amount,
499
+ currency: giftRow.currency,
500
+ claimedBy: giftRow.claimedBy,
501
+ claimedByUsername: giftRow.claimedByUsername,
502
+ claimedAt: giftRow.claimedAt ? giftRow.claimedAt.toISOString() : null,
503
+ createdAt: giftRow.createdAt.toISOString(),
504
+ };
505
+ }
506
+ async doSendRain(input, actorId) {
507
+ await this.verifyRoomAccessIfNeeded(input.roomId, actorId);
508
+ const config = await this.loadCommandConfig('rain');
509
+ if (config?.maxAmount !== undefined &&
510
+ moneyToNumber(input.amount) > moneyToNumber(config.maxAmount)) {
511
+ throw new ExceedsLimitError();
512
+ }
513
+ if (config?.minAmount !== undefined &&
514
+ moneyToNumber(input.amount) < moneyToNumber(config.minAmount)) {
515
+ throw new BelowMinimumError();
516
+ }
517
+ const configMax = config?.maxRecipients ?? 50;
518
+ if (input.recipientCount > configMax) {
519
+ throw new ExceedsLimitError();
520
+ }
521
+ const amountUnits = Math.floor(moneyToNumber(input.amount));
522
+ if (input.recipientCount > amountUnits) {
523
+ throw new TooManyRecipientsError();
524
+ }
525
+ const fingerprint = fingerprintCommand({ type: 'rain', ...input });
526
+ const replay = await this.findCommandReplay('rain', actorId, input.idempotencyKey, fingerprint);
527
+ if (replay) {
528
+ return replay;
529
+ }
530
+ await this.reserveCommandIdempotency('rain', actorId, input.idempotencyKey, fingerprint);
531
+ // Recipients resolved INSIDE runWithCommandReservation's callback (unlike doSendGift's
532
+ // pre-reservation directory lookup): runWithCommandReservation only releases the reservation
533
+ // on a failure inside its own callback, so throwing NoOnlineUsersError here - after
534
+ // reserveCommandIdempotency but before the callback - would otherwise leak a stuck reservation
535
+ // for the full TTL. It must also stay after findCommandReplay/reserveCommandIdempotency so a
536
+ // replay with a now-empty onlineUserIds list (the caller retried after everyone left) still
537
+ // returns the original stored result instead of a spurious NoOnlineUsersError.
538
+ const { msg, currency, totalDistributed, recipientIds } = await this.runWithCommandReservation('rain', actorId, input.idempotencyKey, () => {
539
+ const recipients = shuffleArray(input.onlineUserIds.filter((id) => id !== actorId)).slice(0, input.recipientCount);
540
+ if (recipients.length === 0) {
541
+ throw new NoOnlineUsersError();
542
+ }
543
+ return this.drizzle.db.transaction(async (tx) => {
544
+ const splitResult = await tx.execute(sql `SELECT
545
+ (floor(floor(${input.amount}::numeric) / ${recipients.length}))::text AS per_recipient,
546
+ (floor(floor(${input.amount}::numeric) / ${recipients.length}) * ${recipients.length})::text AS total_distributed`);
547
+ const { per_recipient: perRecipient, total_distributed: totalDistributed } = splitResult
548
+ .rows[0];
549
+ const debit = await this.wallet.debit(tx, {
550
+ userId: actorId,
551
+ amount: totalDistributed,
552
+ type: 'rain',
553
+ });
554
+ if (!debit.ok) {
555
+ throw new InsufficientBalanceError();
556
+ }
557
+ const credits = await mapConcurrent(recipients, 10, (userId) => this.wallet.credit(tx, {
558
+ userId,
559
+ amount: perRecipient,
560
+ currency: debit.currency,
561
+ type: 'rain',
562
+ }));
563
+ if (credits.some((c) => !c.ok)) {
564
+ throw new RainCreditError();
565
+ }
566
+ const [rainRow] = await tx
567
+ .insert(playerRain)
568
+ .values({
569
+ senderId: actorId,
570
+ amount: totalDistributed,
571
+ perRecipient,
572
+ currency: debit.currency,
573
+ roomId: input.roomId,
574
+ recipientCount: recipients.length,
575
+ })
576
+ .returning();
577
+ if (!rainRow) {
578
+ throw new RainCreditError();
579
+ }
580
+ await tx.insert(playerRainReceiver).values(recipients.map((recipientId) => ({
581
+ rainId: rainRow.id,
582
+ recipientId,
583
+ amount: perRecipient,
584
+ })));
585
+ const systemMsg = await this.systemWriter.postSystemMessage({
586
+ roomId: input.roomId,
587
+ actorId,
588
+ tx,
589
+ metadata: {
590
+ command: 'rain',
591
+ fromUserId: actorId,
592
+ amount: totalDistributed,
593
+ currency: debit.currency,
594
+ recipientCount: recipients.length,
595
+ perRecipient,
596
+ },
597
+ });
598
+ await this.audit.recordInTransaction(tx, {
599
+ actorId,
600
+ actorType: 'player',
601
+ action: 'chat.rain',
602
+ resourceType: 'player_rain',
603
+ resourceId: rainRow.id,
604
+ before: null,
605
+ after: { amount: totalDistributed, recipientCount: recipients.length },
606
+ });
607
+ return {
608
+ msg: systemMsg,
609
+ currency: debit.currency,
610
+ totalDistributed,
611
+ perRecipient,
612
+ recipientIds: recipients,
613
+ };
614
+ });
615
+ });
616
+ await this.completeCommandIdempotency('rain', actorId, input.idempotencyKey, fingerprint, msg);
617
+ // The caller now owns the commit boundary: postSystemMessage was passed `tx` above so
618
+ // it did not auto-publish - publish only now that this transaction has committed.
619
+ void this.transport.publish(chatChannel(input.roomId), msg);
620
+ void this.events.emit('chat.rain.distributed', {
621
+ fromUserId: actorId,
622
+ recipients: recipientIds,
623
+ recipientCount: recipientIds.length,
624
+ totalAmount: totalDistributed,
625
+ currency,
626
+ roomId: input.roomId,
627
+ });
628
+ return msg;
629
+ }
630
+ async doSendDonate(input, actorId) {
631
+ await this.verifyRoomAccessIfNeeded(input.roomId, actorId);
632
+ const config = await this.loadCommandConfig('donate');
633
+ if (config?.maxAmount !== undefined &&
634
+ moneyToNumber(input.amount) > moneyToNumber(config.maxAmount)) {
635
+ throw new ExceedsLimitError();
636
+ }
637
+ if (config?.minAmount !== undefined &&
638
+ moneyToNumber(input.amount) < moneyToNumber(config.minAmount)) {
639
+ throw new BelowMinimumError();
640
+ }
641
+ const target = await this.resolveExactPlayer(input.targetUsername);
642
+ if (target.userId === actorId) {
643
+ throw new DonateSelfError();
644
+ }
645
+ const senderSummaries = await this.directory.lookupPlayers([actorId]);
646
+ const sender = senderSummaries.find((s) => s.userId === actorId);
647
+ if (!sender) {
648
+ throw new ChatPlayerNotFoundError(actorId);
649
+ }
650
+ const fingerprint = fingerprintCommand({ type: 'donate', ...input });
651
+ const replay = await this.findCommandReplay('donate', actorId, input.idempotencyKey, fingerprint);
652
+ if (replay) {
653
+ return replay;
654
+ }
655
+ await this.reserveCommandIdempotency('donate', actorId, input.idempotencyKey, fingerprint);
656
+ const { msg, currency } = await this.runWithCommandReservation('donate', actorId, input.idempotencyKey, () => this.drizzle.db.transaction(async (tx) => {
657
+ const debit = await this.wallet.debit(tx, {
658
+ userId: actorId,
659
+ amount: input.amount,
660
+ type: 'tip',
661
+ });
662
+ if (!debit.ok) {
663
+ throw new InsufficientBalanceError();
664
+ }
665
+ const credit = await this.wallet.credit(tx, {
666
+ userId: target.userId,
667
+ amount: input.amount,
668
+ currency: debit.currency,
669
+ type: 'tip',
670
+ });
671
+ if (!credit.ok) {
672
+ throw new ChatPlayerNotFoundError(target.userId);
673
+ }
674
+ const systemMsg = await this.systemWriter.postSystemMessage({
675
+ roomId: input.roomId,
676
+ actorId,
677
+ tx,
678
+ metadata: {
679
+ command: 'donate',
680
+ recipientId: target.userId,
681
+ recipientUsername: target.username,
682
+ amount: input.amount,
683
+ currency: debit.currency,
684
+ },
685
+ });
686
+ const [donateRow] = await tx
687
+ .insert(playerDonate)
688
+ .values({
689
+ senderId: actorId,
690
+ senderUsername: sender.username,
691
+ recipientId: target.userId,
692
+ recipientUsername: target.username,
693
+ amount: input.amount,
694
+ currency: debit.currency,
695
+ roomId: input.roomId,
696
+ })
697
+ .returning();
698
+ if (!donateRow) {
699
+ throw new ChatPlayerNotFoundError(target.userId);
700
+ }
701
+ await this.audit.recordInTransaction(tx, {
702
+ actorId,
703
+ actorType: 'player',
704
+ action: 'chat.donate',
705
+ resourceType: 'player_donate',
706
+ resourceId: donateRow.id,
707
+ before: null,
708
+ after: { recipientId: target.userId, amount: input.amount, currency: debit.currency },
709
+ });
710
+ return { msg: systemMsg, currency: debit.currency };
711
+ }));
712
+ await this.completeCommandIdempotency('donate', actorId, input.idempotencyKey, fingerprint, msg);
713
+ void this.transport.publish(chatChannel(input.roomId), msg);
714
+ void this.events.emit('chat.donate.sent', {
715
+ senderId: actorId,
716
+ senderUsername: sender.username,
717
+ recipientId: target.userId,
718
+ recipientUsername: target.username,
719
+ amount: input.amount,
720
+ currency,
721
+ roomId: input.roomId,
722
+ });
723
+ return msg;
724
+ }
725
+ }
726
+ //# sourceMappingURL=social-transfers.service.js.map