@hed-hog/core 0.0.400 → 0.0.401

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 (203) hide show
  1. package/dist/ai/ai.controller.d.ts +2 -0
  2. package/dist/ai/ai.controller.d.ts.map +1 -1
  3. package/dist/ai/ai.service.d.ts +86 -2
  4. package/dist/ai/ai.service.d.ts.map +1 -1
  5. package/dist/ai/ai.service.js +304 -23
  6. package/dist/ai/ai.service.js.map +1 -1
  7. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
  8. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
  9. package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
  10. package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
  11. package/dist/core.module.d.ts.map +1 -1
  12. package/dist/core.module.js +3 -0
  13. package/dist/core.module.js.map +1 -1
  14. package/dist/document-kit/document-kit.module.d.ts +3 -0
  15. package/dist/document-kit/document-kit.module.d.ts.map +1 -0
  16. package/dist/document-kit/document-kit.module.js +24 -0
  17. package/dist/document-kit/document-kit.module.js.map +1 -0
  18. package/dist/document-kit/document-kit.registry.d.ts +55 -0
  19. package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
  20. package/dist/document-kit/document-kit.registry.js +91 -0
  21. package/dist/document-kit/document-kit.registry.js.map +1 -0
  22. package/dist/document-kit/document-kit.service.d.ts +49 -0
  23. package/dist/document-kit/document-kit.service.d.ts.map +1 -0
  24. package/dist/document-kit/document-kit.service.js +135 -0
  25. package/dist/document-kit/document-kit.service.js.map +1 -0
  26. package/dist/file/file.service.d.ts +15 -1
  27. package/dist/file/file.service.d.ts.map +1 -1
  28. package/dist/file/file.service.js +52 -24
  29. package/dist/file/file.service.js.map +1 -1
  30. package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
  31. package/dist/file/mcp-file-source.schema.js +8 -5
  32. package/dist/file/mcp-file-source.schema.js.map +1 -1
  33. package/dist/index.d.ts +5 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  38. package/dist/integration-profile/integration-profile.secrets.js +21 -0
  39. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  40. package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
  41. package/dist/integration-profile/testers/ai.testers.js +46 -8
  42. package/dist/integration-profile/testers/ai.testers.js.map +1 -1
  43. package/dist/mcp/mcp.module.d.ts.map +1 -1
  44. package/dist/mcp/mcp.module.js +2 -0
  45. package/dist/mcp/mcp.module.js.map +1 -1
  46. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
  47. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
  48. package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
  49. package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
  50. package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
  51. package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
  52. package/dist/meeting/meeting-provider.registry.d.ts +72 -0
  53. package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
  54. package/dist/meeting/meeting-provider.registry.js.map +1 -1
  55. package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
  56. package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
  57. package/dist/oauth/oauth-token-broker.service.js +105 -3
  58. package/dist/oauth/oauth-token-broker.service.js.map +1 -1
  59. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
  60. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  61. package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
  62. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  63. package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
  64. package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
  65. package/dist/pdf/document/charts/chart-bar.js +117 -0
  66. package/dist/pdf/document/charts/chart-bar.js.map +1 -0
  67. package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
  68. package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
  69. package/dist/pdf/document/charts/chart-donut.js +89 -0
  70. package/dist/pdf/document/charts/chart-donut.js.map +1 -0
  71. package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
  72. package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
  73. package/dist/pdf/document/charts/chart-gauge.js +60 -0
  74. package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
  75. package/dist/pdf/document/charts/chart-line.d.ts +29 -0
  76. package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
  77. package/dist/pdf/document/charts/chart-line.js +89 -0
  78. package/dist/pdf/document/charts/chart-line.js.map +1 -0
  79. package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
  80. package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
  81. package/dist/pdf/document/charts/chart-primitives.js +110 -0
  82. package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
  83. package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
  84. package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
  85. package/dist/pdf/document/charts/chart-radar.js +83 -0
  86. package/dist/pdf/document/charts/chart-radar.js.map +1 -0
  87. package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
  88. package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
  89. package/dist/pdf/document/charts/chart-scale.js +118 -0
  90. package/dist/pdf/document/charts/chart-scale.js.map +1 -0
  91. package/dist/pdf/document/charts/index.d.ts +8 -0
  92. package/dist/pdf/document/charts/index.d.ts.map +1 -0
  93. package/dist/pdf/document/charts/index.js +24 -0
  94. package/dist/pdf/document/charts/index.js.map +1 -0
  95. package/dist/pdf/document/index.d.ts +8 -0
  96. package/dist/pdf/document/index.d.ts.map +1 -0
  97. package/dist/pdf/document/index.js +24 -0
  98. package/dist/pdf/document/index.js.map +1 -0
  99. package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
  100. package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
  101. package/dist/pdf/document/pdf-document.chrome.js +36 -0
  102. package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
  103. package/dist/pdf/document/pdf-document.css.d.ts +20 -0
  104. package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
  105. package/dist/pdf/document/pdf-document.css.js +239 -0
  106. package/dist/pdf/document/pdf-document.css.js.map +1 -0
  107. package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
  108. package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
  109. package/dist/pdf/document/pdf-document.renderer.js +153 -0
  110. package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
  111. package/dist/pdf/document/pdf-document.service.d.ts +27 -0
  112. package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
  113. package/dist/pdf/document/pdf-document.service.js +81 -0
  114. package/dist/pdf/document/pdf-document.service.js.map +1 -0
  115. package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
  116. package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
  117. package/dist/pdf/document/pdf-document.theme.js +105 -0
  118. package/dist/pdf/document/pdf-document.theme.js.map +1 -0
  119. package/dist/pdf/document/pdf-document.types.d.ts +108 -0
  120. package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
  121. package/dist/pdf/document/pdf-document.types.js +18 -0
  122. package/dist/pdf/document/pdf-document.types.js.map +1 -0
  123. package/dist/pdf/pdf.module.d.ts.map +1 -1
  124. package/dist/pdf/pdf.module.js +3 -2
  125. package/dist/pdf/pdf.module.js.map +1 -1
  126. package/dist/pdf/pdf.service.d.ts +69 -1
  127. package/dist/pdf/pdf.service.d.ts.map +1 -1
  128. package/dist/pdf/pdf.service.js +164 -63
  129. package/dist/pdf/pdf.service.js.map +1 -1
  130. package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
  131. package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
  132. package/dist/pdf/templates/commercial-proposal.template.js +276 -0
  133. package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
  134. package/dist/setting/favicon.controller.d.ts +26 -0
  135. package/dist/setting/favicon.controller.d.ts.map +1 -0
  136. package/dist/setting/favicon.controller.js +130 -0
  137. package/dist/setting/favicon.controller.js.map +1 -0
  138. package/dist/setting/setting.module.d.ts.map +1 -1
  139. package/dist/setting/setting.module.js +2 -1
  140. package/dist/setting/setting.module.js.map +1 -1
  141. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
  142. package/dist/whatsapp-sender/whatsapp-sender.service.js +25 -2
  143. package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
  144. package/hedhog/data/integration_type.yaml +6 -0
  145. package/hedhog/data/route.yaml +8 -0
  146. package/hedhog/data/setting_group.yaml +52 -0
  147. package/hedhog/frontend/messages/en.json +54 -1
  148. package/hedhog/frontend/messages/pt.json +54 -1
  149. package/package.json +4 -4
  150. package/src/ai/ai-attachment.fragments.spec.ts +56 -3
  151. package/src/ai/ai.service.responses.spec.ts +429 -0
  152. package/src/ai/ai.service.ts +409 -27
  153. package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
  154. package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
  155. package/src/core.module.ts +3 -0
  156. package/src/document-kit/document-kit.module.ts +12 -0
  157. package/src/document-kit/document-kit.registry.ts +125 -0
  158. package/src/document-kit/document-kit.service.spec.ts +156 -0
  159. package/src/document-kit/document-kit.service.ts +173 -0
  160. package/src/file/file.service.spec.ts +168 -1
  161. package/src/file/file.service.ts +72 -34
  162. package/src/file/mcp-file-source.schema.ts +8 -5
  163. package/src/index.ts +5 -0
  164. package/src/integration-profile/integration-profile.secrets.ts +21 -0
  165. package/src/integration-profile/testers/ai.testers.ts +70 -5
  166. package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
  167. package/src/integration-profile/testers/tester-coverage.spec.ts +10 -0
  168. package/src/mcp/mcp.module.ts +2 -0
  169. package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
  170. package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
  171. package/src/meeting/meeting-provider.registry.ts +80 -0
  172. package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
  173. package/src/oauth/oauth-token-broker.service.ts +129 -2
  174. package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
  175. package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
  176. package/src/pdf/document/charts/chart-bar.ts +217 -0
  177. package/src/pdf/document/charts/chart-donut.ts +163 -0
  178. package/src/pdf/document/charts/chart-gauge.ts +94 -0
  179. package/src/pdf/document/charts/chart-line.ts +159 -0
  180. package/src/pdf/document/charts/chart-primitives.ts +188 -0
  181. package/src/pdf/document/charts/chart-radar.ts +136 -0
  182. package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
  183. package/src/pdf/document/charts/chart-scale.ts +141 -0
  184. package/src/pdf/document/charts/charts.spec.ts +157 -0
  185. package/src/pdf/document/charts/index.ts +7 -0
  186. package/src/pdf/document/index.ts +7 -0
  187. package/src/pdf/document/pdf-document.chrome.ts +38 -0
  188. package/src/pdf/document/pdf-document.css.ts +238 -0
  189. package/src/pdf/document/pdf-document.renderer.spec.ts +129 -0
  190. package/src/pdf/document/pdf-document.renderer.ts +215 -0
  191. package/src/pdf/document/pdf-document.service.ts +77 -0
  192. package/src/pdf/document/pdf-document.theme.ts +146 -0
  193. package/src/pdf/document/pdf-document.types.ts +97 -0
  194. package/src/pdf/pdf.module.ts +3 -2
  195. package/src/pdf/pdf.service.spec.ts +131 -0
  196. package/src/pdf/pdf.service.ts +245 -82
  197. package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
  198. package/src/pdf/templates/commercial-proposal.template.ts +400 -0
  199. package/src/setting/favicon.controller.spec.ts +92 -0
  200. package/src/setting/favicon.controller.ts +135 -0
  201. package/src/setting/setting.module.ts +2 -1
  202. package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +73 -0
  203. package/src/whatsapp-sender/whatsapp-sender.service.ts +32 -2
@@ -32,6 +32,7 @@ import { McpPermissionService } from './mcp-permission.service';
32
32
  import { McpRegistryService } from './mcp-registry.service';
33
33
  import { McpServerService } from './mcp-server.service';
34
34
  import { McpController } from './mcp.controller';
35
+ import { CoreAccessLogMcpTools } from './tools/core-access-log.mcp-tools';
35
36
  import { CoreAiMcpTools } from './tools/core-ai.mcp-tools';
36
37
  import { CoreDashboardItemsMcpTools } from './tools/core-dashboard-items.mcp-tools';
37
38
  import { CoreDashboardMcpTools } from './tools/core-dashboard.mcp-tools';
@@ -111,6 +112,7 @@ import { AccessLogModule } from '../access-log/access-log.module';
111
112
  CoreAiMcpTools,
112
113
  CoreMcpChatMcpTools,
113
114
  CoreSystemMcpTools,
115
+ CoreAccessLogMcpTools,
114
116
  ],
115
117
  exports: [McpRegistryService, McpServerService, McpApiKeyService, McpPermissionService],
116
118
  })
@@ -0,0 +1,66 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { AccessLogService } from '../../access-log/access-log.service';
3
+ import { McpTool } from '../decorators/mcp-tool.decorator';
4
+ import { McpContext } from '../types/mcp-context.type';
5
+
6
+ const TYPES = ['http', 'mcp', 'all'] as const;
7
+ const SIMULATED = ['all', 'only', 'exclude'] as const;
8
+
9
+ @Injectable()
10
+ export class CoreAccessLogMcpTools {
11
+ constructor(private readonly accessLogService: AccessLogService) {}
12
+
13
+ @McpTool({
14
+ name: 'core.access-log.list',
15
+ description:
16
+ 'Lists the access log (the admin "Access log" screen): every authenticated HTTP request and MCP tool call, with user, method/URL or tool, IP, user agent and whether it happened during an access simulation. Newest first.',
17
+ inputSchema: {
18
+ type: 'object',
19
+ properties: {
20
+ page: { type: 'number', description: 'Page number (default: 1)' },
21
+ pageSize: { type: 'number', description: 'Items per page (default: 20)' },
22
+ userId: { type: 'number', description: 'Filter by user id' },
23
+ userSearch: { type: 'string', description: 'Matches the user name' },
24
+ createdAtFrom: { type: 'string', description: 'Start date (YYYY-MM-DD)' },
25
+ createdAtTo: { type: 'string', description: 'End date (YYYY-MM-DD), inclusive' },
26
+ type: {
27
+ type: 'string',
28
+ description: 'http = REST requests, mcp = MCP tool calls (default: all)',
29
+ enum: [...TYPES],
30
+ },
31
+ simulated: {
32
+ type: 'string',
33
+ description: 'only/exclude accesses made while an admin simulated another user (default: all)',
34
+ enum: [...SIMULATED],
35
+ },
36
+ },
37
+ },
38
+ readOnly: true,
39
+ })
40
+ async list(
41
+ args: {
42
+ page?: number;
43
+ pageSize?: number;
44
+ userId?: number;
45
+ userSearch?: string;
46
+ createdAtFrom?: string;
47
+ createdAtTo?: string;
48
+ type?: string;
49
+ simulated?: string;
50
+ },
51
+ _context: McpContext,
52
+ ): Promise<any> {
53
+ return this.accessLogService.list({
54
+ page: args.page ?? 1,
55
+ pageSize: args.pageSize ?? 20,
56
+ userId: args.userId,
57
+ userSearch: args.userSearch,
58
+ createdAtFrom: args.createdAtFrom,
59
+ createdAtTo: args.createdAtTo,
60
+ type: TYPES.includes(args.type as any) ? (args.type as (typeof TYPES)[number]) : undefined,
61
+ simulated: SIMULATED.includes(args.simulated as any)
62
+ ? (args.simulated as (typeof SIMULATED)[number])
63
+ : undefined,
64
+ });
65
+ }
66
+ }
@@ -130,7 +130,7 @@ export class CoreFilesMcpTools {
130
130
  @McpTool({
131
131
  name: 'core.files.create-upload-url',
132
132
  description:
133
- 'Opens an upload and returns a short-lived, single-use URL to PUT the file to. This is how you send a file without putting its bytes in the conversation: call this, PUT the binary to the returned url, then call core.files.upload.complete to get the fileId, and pass that fileId to whichever tool needs the file. Works for files up to the storage-max-size setting (10mb by default).',
133
+ 'Opens an upload and returns a short-lived, single-use URL on the Hub API host to PUT the file to: PUT the binary to the returned url, then call core.files.upload.complete to get the fileId (or pass the uploadId straight to the tool that needs the file). Fastest path, but the url must be reachable from wherever the PUT runs. Clients in a network-restricted sandbox (e.g. claude.ai) usually cannot reach it; if you are in one, or the PUT fails with host_not_allowed, 403 or a network error, use core.files.upload.init instead, which sends the file through MCP calls. Works for files up to the storage-max-size setting (10mb by default).',
134
134
  inputSchema: {
135
135
  type: 'object',
136
136
  properties: {
@@ -185,14 +185,14 @@ export class CoreFilesMcpTools {
185
185
  headers: { 'Content-Type': ticket.mimetype },
186
186
  expiresAt: ticket.expires_at.toISOString(),
187
187
  sizeBytes: ticket.size_bytes,
188
- next: 'PUT the raw file bytes to url, then call core.files.upload.complete with this uploadId.',
188
+ next: 'PUT the raw file bytes to url, then call core.files.upload.complete with this uploadId. If the PUT is blocked (host_not_allowed, 403, network error), open a new upload with core.files.upload.init and send the file in chunks instead.',
189
189
  };
190
190
  }
191
191
 
192
192
  @McpTool({
193
193
  name: 'core.files.upload.init',
194
194
  description:
195
- 'Opens an upload to be sent in base64 chunks. Only for clients that cannot make an HTTP request: it is far slower than core.files.create-upload-url, because the content still travels through the conversation, just split across calls. Follow with core.files.upload.append for each chunk and core.files.upload.complete at the end.',
195
+ 'Opens an upload sent in base64 chunks through MCP calls, with no extra network access. Use it when core.files.create-upload-url is not an option: a network-restricted sandbox (e.g. claude.ai), or a PUT that failed with host_not_allowed, 403 or a network error. Works for files up to the storage-max-size setting (10mb by default), well beyond the ~256kb limit of inline fileBase64. Returns uploadId, chunkBytes and totalChunks. Then call core.files.upload.append once per chunk (index 0..totalChunks-1, each chunk at most chunkBytes raw bytes before base64 encoding) and finish with core.files.upload.complete to get the fileId, or pass the uploadId to the tool that needs the file. Generate the chunks programmatically in your sandbox (split the file and print the base64 of each chunk) rather than typing base64 by hand. The upload expires at expiresAt.',
196
196
  inputSchema: {
197
197
  type: 'object',
198
198
  properties: {
@@ -225,7 +225,7 @@ export class CoreFilesMcpTools {
225
225
  chunkBytes,
226
226
  totalChunks: Math.ceil(ticket.size_bytes / chunkBytes),
227
227
  expiresAt: ticket.expires_at.toISOString(),
228
- next: `Send each chunk with core.files.upload.append, indexes 0..n-1, at most ${chunkBytes} bytes before encoding.`,
228
+ next: `Send each chunk with core.files.upload.append, indexes 0..${Math.ceil(ticket.size_bytes / chunkBytes) - 1}, at most ${chunkBytes} raw bytes per chunk before base64 encoding, then call core.files.upload.complete with this uploadId.`,
229
229
  };
230
230
  }
231
231
 
@@ -1,4 +1,5 @@
1
1
  import { Injectable, Logger } from '@nestjs/common';
2
+ import { MeetingChatMessage } from './meeting.constants';
2
3
 
3
4
  /**
4
5
  * Pedido de criação de uma reunião online para uma entidade de negócio qualquer.
@@ -218,6 +219,61 @@ export interface MeetingChatAttachmentContent {
218
219
  size: number;
219
220
  }
220
221
 
222
+ /**
223
+ * Pedido de publicação de uma mensagem no chat da reunião de uma origem.
224
+ *
225
+ * O corpo é HTML simples — `<p>`, `<br>`, `<strong>`, `<a href>` — e não texto
226
+ * puro porque o que motiva este contrato é mandar um link clicável para dentro
227
+ * da reunião. Provedor que não renderize HTML degrada tirando as tags; o texto
228
+ * continua legível.
229
+ */
230
+ export interface PostMeetingChatMessageInput {
231
+ sourceModule: string;
232
+ sourceEntity: string;
233
+ sourceEntityId: string;
234
+ html: string;
235
+ }
236
+
237
+ /**
238
+ * Desfecho da publicação, sem exceção.
239
+ *
240
+ * Postar no chat é conveniência, não o caminho crítico: quem chama tem sempre o
241
+ * plano B de mostrar o texto para alguém colar à mão. Transformar "o tenant
242
+ * desligou o chat da reunião" em exceção obrigaria toda tela a tratar erro para
243
+ * uma situação que é normal.
244
+ */
245
+ export type PostMeetingChatFailure =
246
+ /** Não há identidade/configuração para escrever. */
247
+ | 'unavailable'
248
+ /** A reunião não tem chat (ninguém entrou, ou o tenant o desligou). */
249
+ | 'no_chat'
250
+ /** Falta consentimento do escopo de envio na conta que escreve. */
251
+ | 'missing_scope'
252
+ /** O provedor recusou por outro motivo. */
253
+ | 'failed';
254
+
255
+ /**
256
+ * Um objeto com campos opcionais, e não uma união discriminada, porque o
257
+ * monorepo compila com `strictNullChecks: false` — e sem essa flag o
258
+ * TypeScript não estreita a união pelo `ok`, obrigando todo consumidor a um
259
+ * cast. Melhor um contrato que funciona do que um mais elegante que ninguém
260
+ * consegue usar sem contorno.
261
+ *
262
+ * `ok: true` traz `providerMessageId`; `ok: false` traz `reason` e `message`.
263
+ */
264
+ export interface PostMeetingChatMessageResult {
265
+ ok: boolean;
266
+ providerMessageId?: string | null;
267
+ reason?: PostMeetingChatFailure;
268
+ message?: string;
269
+ }
270
+
271
+ export interface ListMeetingChatMessagesInput {
272
+ sourceModule: string;
273
+ sourceEntity: string;
274
+ sourceEntityId: string;
275
+ }
276
+
221
277
  /**
222
278
  * Provedor de reuniões online (Teams hoje; Meet/Zoom cabem no mesmo contrato).
223
279
  *
@@ -269,6 +325,30 @@ export interface IMeetingProvider {
269
325
  fetchChatAttachment?(
270
326
  input: FetchMeetingChatAttachmentInput,
271
327
  ): Promise<MeetingChatAttachmentContent | null>;
328
+ /**
329
+ * Publica uma mensagem no chat da reunião.
330
+ *
331
+ * Opcional pelo mesmo motivo de {@link fetchChatAttachment}, e nunca lança:
332
+ * o desfecho vem no retorno.
333
+ */
334
+ postChatMessage?(
335
+ input: PostMeetingChatMessageInput,
336
+ ): Promise<PostMeetingChatMessageResult>;
337
+ /**
338
+ * As mensagens do chat que o provedor já capturou para esta origem.
339
+ *
340
+ * Existe porque o evento `MEETING_CHAT_AVAILABLE_EVENT` tem teto de mensagens
341
+ * e, quando estoura, entrega as mais RECENTES. Isso serve para achar o
342
+ * material que o instrutor colou no fim da aula, e não serve para uma turma
343
+ * de 250 pessoas em que cada uma escreve: as primeiras somem do payload.
344
+ *
345
+ * Lê o que o provedor já guardou, sem ida nova ao serviço remoto — é a
346
+ * alternativa a fazer o módulo de origem ler a tabela do provedor, que é
347
+ * exatamente o acoplamento que este contrato existe para evitar.
348
+ */
349
+ listChatMessages?(
350
+ input: ListMeetingChatMessagesInput,
351
+ ): Promise<MeetingChatMessage[]>;
272
352
  }
273
353
 
274
354
  /**
@@ -70,6 +70,32 @@ describe('OAuthTokenBrokerService', () => {
70
70
  token_expires_at: new Date('2020-01-01T00:00:00Z'),
71
71
  });
72
72
 
73
+ /**
74
+ * O Prisma 6 devolve coluna `Bytes` como `Uint8Array`, não como `Buffer`. Os
75
+ * mocks daqui usavam `Buffer`, então os testes passavam enquanto produção
76
+ * falhava em TODA conta: `Uint8Array.toString()` imprime os bytes em decimal
77
+ * ("115,97,...") em vez de decodificar utf8, o decrypt não achava as 4 partes
78
+ * e o usuário via "reconecte a conta" — que reconectar não resolvia.
79
+ */
80
+ it('decrypts a Bytes column that chega como Uint8Array (Prisma 6)', async () => {
81
+ const encrypted = deps.security.encrypt('refresh-original');
82
+ deps.prisma.user_account.findFirst.mockResolvedValue({
83
+ id: 42,
84
+ refresh_token: new Uint8Array(Buffer.from(encrypted)),
85
+ scopes: 'openid,offline_access',
86
+ token_expires_at: new Date('2020-01-01T00:00:00Z'),
87
+ } as any);
88
+ deps.http.post.mockReturnValue(
89
+ of({ data: { access_token: 'ACCESS-1', expires_in: 3600 } }) as any,
90
+ );
91
+
92
+ const result = await service.getAccessToken(1, PROVIDER);
93
+
94
+ expect(result.accessToken).toBe('ACCESS-1');
95
+ const [, body] = deps.http.post.mock.calls[0] as [string, string, unknown];
96
+ expect(body).toContain('refresh_token=refresh-original');
97
+ });
98
+
73
99
  it('decrypts the stored refresh token, exchanges it, and returns an access token', async () => {
74
100
  deps.prisma.user_account.findFirst.mockResolvedValue(
75
101
  storedRefreshTokenRow(deps.security, 'refresh-original') as any,
@@ -161,6 +187,88 @@ describe('OAuthTokenBrokerService', () => {
161
187
  });
162
188
  });
163
189
 
190
+ /**
191
+ * A Microsoft rotaciona o refresh token a cada troca e invalida o anterior.
192
+ * Com várias réplicas disparando o mesmo tick de cron, a segunda a chegar
193
+ * recebe `invalid_grant` por uma corrida, não por revogação — e reportar
194
+ * "reconecte a conta" ali é enganoso: reconectar não resolve, a corrida se
195
+ * repete no ciclo seguinte.
196
+ */
197
+ describe('rotação concorrente', () => {
198
+ it('refaz a troca com o token que outro processo gravou', async () => {
199
+ deps.prisma.user_account.findFirst
200
+ // leitura inicial: o token que vai falhar
201
+ .mockResolvedValueOnce(
202
+ storedRefreshTokenRow(deps.security, 'refresh-antigo') as any,
203
+ )
204
+ // releitura após o invalid_grant: outro processo já rotacionou
205
+ .mockResolvedValueOnce(
206
+ storedRefreshTokenRow(deps.security, 'refresh-novo') as any,
207
+ )
208
+ // a retentativa carrega a conta de novo
209
+ .mockResolvedValueOnce(
210
+ storedRefreshTokenRow(deps.security, 'refresh-novo') as any,
211
+ );
212
+
213
+ deps.http.post
214
+ .mockReturnValueOnce(
215
+ throwError(() => ({
216
+ response: { data: { error: 'invalid_grant' }, status: 400 },
217
+ })) as any,
218
+ )
219
+ .mockReturnValueOnce(
220
+ of({ data: { access_token: 'token-bom', expires_in: 3600 } }) as any,
221
+ );
222
+
223
+ const result = await service.getAccessToken(1, PROVIDER);
224
+
225
+ expect(result.accessToken).toBe('token-bom');
226
+ expect(deps.http.post).toHaveBeenCalledTimes(2);
227
+ // A segunda troca tem que usar o token NOVO, não repetir o que falhou.
228
+ const segundaChamada = String((deps.http.post.mock.calls[1] as any[])[1]);
229
+ expect(segundaChamada).toContain('refresh-novo');
230
+ expect(segundaChamada).not.toContain('refresh-antigo');
231
+ });
232
+
233
+ it('ainda reporta revogação quando o token no banco não mudou', async () => {
234
+ // Sem essa distinção, uma conta de fato desconectada entraria em laço.
235
+ deps.prisma.user_account.findFirst.mockResolvedValue(
236
+ storedRefreshTokenRow(deps.security, 'refresh-original') as any,
237
+ );
238
+ deps.http.post.mockReturnValue(
239
+ throwError(() => ({
240
+ response: { data: { error: 'invalid_grant' }, status: 400 },
241
+ })) as any,
242
+ );
243
+
244
+ await expect(service.getAccessToken(1, PROVIDER)).rejects.toMatchObject({
245
+ reason: 'refresh_revoked',
246
+ });
247
+ // Uma tentativa só: não adianta insistir contra revogação real.
248
+ expect(deps.http.post).toHaveBeenCalledTimes(1);
249
+ });
250
+
251
+ it('não persegue rotações indefinidamente', async () => {
252
+ // Cada releitura devolve um token diferente, simulando réplicas
253
+ // rotacionando sem parar. O teto tem que encerrar.
254
+ let n = 0;
255
+ deps.prisma.user_account.findFirst.mockImplementation(
256
+ async () =>
257
+ storedRefreshTokenRow(deps.security, `refresh-${n++}`) as any,
258
+ );
259
+ deps.http.post.mockReturnValue(
260
+ throwError(() => ({
261
+ response: { data: { error: 'invalid_grant' }, status: 400 },
262
+ })) as any,
263
+ );
264
+
265
+ await expect(service.getAccessToken(1, PROVIDER)).rejects.toMatchObject({
266
+ reason: 'refresh_revoked',
267
+ });
268
+ expect(deps.http.post.mock.calls.length).toBeLessThanOrEqual(3);
269
+ });
270
+ });
271
+
164
272
  it('parses granted scopes from the stored account', async () => {
165
273
  deps.prisma.user_account.findFirst.mockResolvedValue({
166
274
  scopes: 'openid, Calendars.Read offline_access',
@@ -78,6 +78,16 @@ export class OAuthTokenBrokerService {
78
78
  /** Refresh a bit early so an in-flight request never rides an about-to-expire token. */
79
79
  private static readonly EXPIRY_SKEW_MS = 60 * 1000;
80
80
 
81
+ /**
82
+ * Quantas vezes seguir um refresh token que outro processo rotacionou.
83
+ *
84
+ * Duas bastam na prática: a corrida acontece quando várias réplicas disparam
85
+ * o mesmo tick de cron, e a segunda tentativa já parte do token que a
86
+ * vencedora gravou. O teto existe para o caso patológico de rotações
87
+ * encadeadas não virar laço.
88
+ */
89
+ private static readonly MAX_ROTATION_RETRIES = 2;
90
+
81
91
  constructor(
82
92
  private readonly http: HttpService,
83
93
  @Inject(forwardRef(() => SettingService))
@@ -169,6 +179,70 @@ export class OAuthTokenBrokerService {
169
179
  return { tokenUrl, clientId, clientSecret };
170
180
  }
171
181
 
182
+ /**
183
+ * Lê uma coluna `Bytes` que guarda texto cifrado.
184
+ *
185
+ * O Prisma 6 passou a devolver `Bytes` como `Uint8Array`, e não mais como
186
+ * `Buffer`. Os dois têm `.toString()`, mas com significados opostos: o do
187
+ * Buffer decodifica os bytes como utf8 ("salt:iv:tag:cifra"), o do
188
+ * Uint8Array imprime os bytes em decimal separados por vírgula
189
+ * ("115,97,108,116,..."). O `SecurityService.decrypt` então não achava as 4
190
+ * partes separadas por ":" e lançava — e como toda conta caía nesse mesmo
191
+ * erro, a mensagem final era "reconecte a conta", que nenhuma reconexão
192
+ * resolvia. `Buffer.from` normaliza os dois casos.
193
+ */
194
+ private readEncryptedColumn(value: Uint8Array | Buffer): string {
195
+ return Buffer.from(value).toString();
196
+ }
197
+
198
+ /**
199
+ * Resumo do erro do provedor para o log. Usa só `error` e `error_description`
200
+ * — campos que a Microsoft e o Google preenchem com código e explicação
201
+ * (AADSTS…), nunca com credencial. O corpo inteiro não vai para o log: a
202
+ * requisição que falhou carrega o client_secret e o refresh token.
203
+ */
204
+ private describeGrantError(error: any): string {
205
+ const data = error?.response?.data;
206
+ const code = data?.error ?? 'unknown';
207
+ const description = String(data?.error_description ?? '')
208
+ .replace(/\s+/g, ' ')
209
+ .trim();
210
+ return description ? `${code}: ${description.slice(0, 300)}` : code;
211
+ }
212
+
213
+ /**
214
+ * Relê o refresh token do banco para ver se outro processo já o rotacionou.
215
+ *
216
+ * Devolve `true` só quando o valor guardado é DIFERENTE do que acabou de
217
+ * falhar — é isso que distingue uma corrida entre réplicas (vale tentar de
218
+ * novo, agora com o token novo) de uma revogação de verdade (não adianta
219
+ * insistir). Comparar os valores importa: sem essa checagem, uma conta
220
+ * realmente desconectada entraria em laço de tentativas.
221
+ *
222
+ * Nunca lança: qualquer problema aqui vira "não rotacionou", e quem chamou
223
+ * segue para o erro original, que é a informação correta a dar ao usuário.
224
+ */
225
+ private async reloadRotatedRefreshToken(
226
+ userId: number,
227
+ provider: string,
228
+ usedRefreshToken: string,
229
+ ): Promise<boolean> {
230
+ try {
231
+ const account = await this.prisma.user_account.findFirst({
232
+ where: { user_id: userId, provider: this.toAccountProvider(provider) },
233
+ select: { refresh_token: true },
234
+ });
235
+ if (!account?.refresh_token) return false;
236
+
237
+ const stored = this.security.decrypt(
238
+ this.readEncryptedColumn(account.refresh_token),
239
+ );
240
+ return Boolean(stored) && stored !== usedRefreshToken;
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+
172
246
  private async loadUserAccount(userId: number, provider: string) {
173
247
  const account = await this.prisma.user_account.findFirst({
174
248
  where: {
@@ -232,6 +306,13 @@ export class OAuthTokenBrokerService {
232
306
  async getAccessToken(
233
307
  userId: number,
234
308
  provider: string,
309
+ /**
310
+ * Profundidade da retentativa após uma rotação concorrente. Existe para a
311
+ * recursão ter fim: numa corrida entre muitas réplicas, cada uma pode
312
+ * rotacionar de novo entre a nossa falha e a releitura, e sem teto duas
313
+ * réplicas azaradas ficariam se revezando indefinidamente.
314
+ */
315
+ attempt = 0,
235
316
  ): Promise<BrokeredAccessToken> {
236
317
  const key = this.cacheKey(userId, provider);
237
318
  const cached = this.cache.get(key);
@@ -249,8 +330,23 @@ export class OAuthTokenBrokerService {
249
330
 
250
331
  let decryptedRefreshToken: string;
251
332
  try {
252
- decryptedRefreshToken = this.security.decrypt(account.refresh_token.toString());
333
+ decryptedRefreshToken = this.security.decrypt(
334
+ this.readEncryptedColumn(account.refresh_token),
335
+ );
253
336
  } catch (error) {
337
+ // Este caminho e o `invalid_grant` lá embaixo chegam ao usuário com a
338
+ // MESMA frase ("reconecte a conta"), e as causas não têm nada a ver uma
339
+ // com a outra: aqui o valor guardado não abre, lá é a Microsoft recusando
340
+ // o grant. Sem distinguir no log, não há como saber qual dos dois ocorreu.
341
+ // A mensagem do decrypt separa os dois motivos possíveis daqui — formato
342
+ // inválido (o valor lido não é o cifrado) e falha de autenticação (chave
343
+ // diferente da que cifrou) —, e é truncada porque ela ecoa o começo do
344
+ // valor que tentou decifrar.
345
+ this.logger.warn(
346
+ `Stored refresh token could not be decrypted: provider=${provider} userId=${userId} detail=${
347
+ error instanceof Error ? error.message.split('Value:')[0].trim() : 'unknown'
348
+ }`,
349
+ );
254
350
  throw new OAuthTokenBrokerError(
255
351
  'refresh_revoked',
256
352
  `Failed to decrypt stored refresh token for "${provider}".`,
@@ -290,8 +386,39 @@ export class OAuthTokenBrokerService {
290
386
  } catch (error: any) {
291
387
  const providerError = error?.response?.data?.error;
292
388
  if (providerError === 'invalid_grant') {
293
- // Refresh token revoked/expired — the user must reconnect.
294
389
  this.cache.delete(key);
390
+
391
+ // Antes de declarar a conta desconectada, checa se outro processo
392
+ // rotacionou o token enquanto este fazia a troca.
393
+ //
394
+ // A Microsoft emite um refresh token novo a cada troca e invalida o
395
+ // anterior, então basta duas réplicas usarem o mesmo token ao mesmo
396
+ // tempo para a segunda levar `invalid_grant` — e este serviço roda em
397
+ // seis. Sem esta releitura, uma corrida entre réplicas era reportada ao
398
+ // usuário como "reconecte a conta", e reconectar não resolvia nada,
399
+ // porque a corrida se repetia no ciclo seguinte.
400
+ const rotated =
401
+ attempt < OAuthTokenBrokerService.MAX_ROTATION_RETRIES &&
402
+ (await this.reloadRotatedRefreshToken(
403
+ userId,
404
+ provider,
405
+ decryptedRefreshToken,
406
+ ));
407
+ if (rotated) {
408
+ this.logger.log(
409
+ `Refresh token was rotated by another process; retrying: provider=${provider} userId=${userId} attempt=${attempt + 1}`,
410
+ );
411
+ return this.getAccessToken(userId, provider, attempt + 1);
412
+ }
413
+
414
+ // Nada mudou no banco: o grant foi mesmo recusado. O `error_description`
415
+ // do provedor é a única coisa que diz POR QUÊ (na Microsoft, o código
416
+ // AADSTS: token expirado, consentimento faltando, acesso condicional).
417
+ // Sem ele no log, "reconecte a conta" é tudo que sobra para diagnosticar
418
+ // — e reconectar não resolve a maioria desses casos.
419
+ this.logger.warn(
420
+ `Refresh grant rejected: provider=${provider} userId=${userId} scopesSent="${scopeParam.join(' ')}" detail=${this.describeGrantError(error)}`,
421
+ );
295
422
  throw new OAuthTokenBrokerError(
296
423
  'refresh_revoked',
297
424
  `Refresh token for "${provider}" is no longer valid. Reconnect the account.`,
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it, jest } from '@jest/globals';
2
+ import { HttpService } from '@nestjs/axios';
3
+ import { of } from 'rxjs';
4
+ import { MicrosoftEntraIdProvider } from './microsoft-entra-id.provider';
5
+
6
+ /**
7
+ * O que estes testes protegem: `user_account.scopes` tem que registrar o que a
8
+ * Microsoft CONCEDEU, não o que a setting pede.
9
+ *
10
+ * Gravar a setting quebrava toda a integração delegada de um jeito que não dava
11
+ * pista nenhuma. O `OAuthTokenBrokerService` monta o `scope` do refresh a partir
12
+ * dessa coluna; se ela promete um escopo que o consentimento não cobre, a
13
+ * Microsoft recusa o grant com `invalid_grant`, o broker traduz para "reconecte
14
+ * a conta" — e reconectar pelo login regrava a mesma setting, então o usuário
15
+ * reconecta, falha, reconecta, falha.
16
+ */
17
+
18
+ const SCOPES_CONFIGURADOS = [
19
+ 'openid',
20
+ 'profile',
21
+ 'email',
22
+ 'User.Read',
23
+ 'offline_access',
24
+ 'Chat.Read',
25
+ 'Files.Read.All',
26
+ ];
27
+
28
+ const makeProvider = (tokenResponse: Record<string, unknown>) => {
29
+ const http = {
30
+ post: jest.fn(() => of({ data: tokenResponse })),
31
+ get: jest.fn(() => of({ data: { id: 'graph-id', mail: 'alguem@exemplo.test', displayName: 'Alguém' } })),
32
+ };
33
+
34
+ const setting = {
35
+ getSettingValues: jest.fn(async (slugs: string[]) => {
36
+ const values: Record<string, unknown> = {
37
+ 'oauth-microsoft-entra-id-profile-id': 'perfil-entra',
38
+ 'oauth-microsoft-entra-id-scopes': SCOPES_CONFIGURADOS,
39
+ url: 'https://app.exemplo.test',
40
+ 'api-url': 'https://api.exemplo.test',
41
+ };
42
+ return Object.fromEntries(slugs.map((slug) => [slug, values[slug]]));
43
+ }),
44
+ };
45
+
46
+ const prisma = {
47
+ integration_profile: {
48
+ findUnique: jest.fn(async () => ({
49
+ slug: 'perfil-entra',
50
+ config: { client_id: 'CID', client_secret: 'SECRET', tenant_id: 'TENANT' },
51
+ integration_provider: { slug: 'microsoft-entra-id-oauth' },
52
+ })),
53
+ },
54
+ };
55
+
56
+ return new MicrosoftEntraIdProvider(
57
+ http as unknown as HttpService,
58
+ setting as any,
59
+ prisma as any,
60
+ );
61
+ };
62
+
63
+ const parse = (profile: any) => String(profile.oauth_scopes).split(',');
64
+
65
+ describe('MicrosoftEntraIdProvider.getProfile — escopos', () => {
66
+ it('grava o que a Microsoft concedeu, não o que a setting pede', async () => {
67
+ // Consentimento antigo: nem Chat.Read nem Files.Read.All foram aprovados.
68
+ const provider = makeProvider({
69
+ access_token: 'ACCESS',
70
+ refresh_token: 'REFRESH',
71
+ expires_in: 3600,
72
+ scope: 'openid profile email User.Read',
73
+ });
74
+
75
+ const scopes = parse(await provider.getProfile('CODE', 'login'));
76
+
77
+ expect(scopes).not.toContain('Chat.Read');
78
+ expect(scopes).not.toContain('Files.Read.All');
79
+ expect(scopes).toContain('User.Read');
80
+ });
81
+
82
+ it('mantém offline_access quando veio refresh token, mesmo fora do scope', async () => {
83
+ // A Microsoft não repete `offline_access` em `token.scope`. Perdê-lo faria
84
+ // o broker tratar a conta como sem acesso offline.
85
+ const provider = makeProvider({
86
+ access_token: 'ACCESS',
87
+ refresh_token: 'REFRESH',
88
+ expires_in: 3600,
89
+ scope: 'openid profile email User.Read',
90
+ });
91
+
92
+ expect(parse(await provider.getProfile('CODE', 'login'))).toContain(
93
+ 'offline_access',
94
+ );
95
+ });
96
+
97
+ it('não inventa offline_access quando não houve refresh token', async () => {
98
+ const provider = makeProvider({
99
+ access_token: 'ACCESS',
100
+ expires_in: 3600,
101
+ scope: 'openid profile email',
102
+ });
103
+
104
+ expect(parse(await provider.getProfile('CODE', 'login'))).not.toContain(
105
+ 'offline_access',
106
+ );
107
+ });
108
+
109
+ it('cai para os escopos pedidos só quando a Microsoft não informa nenhum', async () => {
110
+ // Ausência de `scope` é omissão do provedor; lista vazia seria resposta.
111
+ const provider = makeProvider({
112
+ access_token: 'ACCESS',
113
+ refresh_token: 'REFRESH',
114
+ expires_in: 3600,
115
+ });
116
+
117
+ const scopes = parse(await provider.getProfile('CODE', 'login'));
118
+
119
+ expect(scopes).toEqual(expect.arrayContaining(SCOPES_CONFIGURADOS));
120
+ });
121
+
122
+ it('registra escopo concedido vazio como vazio, sem assumir o pedido', async () => {
123
+ const provider = makeProvider({
124
+ access_token: 'ACCESS',
125
+ expires_in: 3600,
126
+ scope: ' ',
127
+ });
128
+
129
+ expect(parse(await provider.getProfile('CODE', 'login'))).not.toContain(
130
+ 'Files.Read.All',
131
+ );
132
+ });
133
+ });