@kodelyth/nextcloud-talk 2026.5.39 → 2026.5.42

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 (78) hide show
  1. package/api.ts +1 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/contract-api.ts +4 -0
  4. package/dist/api.js +2 -0
  5. package/dist/channel-ej3z6XJ5.js +2094 -0
  6. package/dist/channel-plugin-api.js +2 -0
  7. package/dist/contract-api.js +2 -0
  8. package/dist/doctor-contract-Dia7keG4.js +7 -0
  9. package/dist/doctor-contract-api.js +2 -0
  10. package/dist/index.js +22 -0
  11. package/dist/runtime-api-DCIDXlUd.js +14 -0
  12. package/dist/runtime-api.js +2 -0
  13. package/dist/secret-contract-DQ2wQ4m1.js +86 -0
  14. package/dist/secret-contract-api.js +2 -0
  15. package/dist/setup-entry.js +15 -0
  16. package/doctor-contract-api.ts +1 -0
  17. package/index.ts +20 -0
  18. package/klaw.plugin.json +2 -799
  19. package/package.json +4 -4
  20. package/runtime-api.ts +29 -0
  21. package/secret-contract-api.ts +5 -0
  22. package/setup-entry.ts +13 -0
  23. package/src/accounts.test.ts +31 -0
  24. package/src/accounts.ts +149 -0
  25. package/src/api-credentials.ts +31 -0
  26. package/src/approval-auth.test.ts +17 -0
  27. package/src/approval-auth.ts +27 -0
  28. package/src/bot-preflight.test.ts +135 -0
  29. package/src/bot-preflight.ts +183 -0
  30. package/src/channel-api.ts +5 -0
  31. package/src/channel.adapters.ts +52 -0
  32. package/src/channel.core.test.ts +75 -0
  33. package/src/channel.lifecycle.test.ts +91 -0
  34. package/src/channel.status.test.ts +28 -0
  35. package/src/channel.ts +225 -0
  36. package/src/config-schema.ts +79 -0
  37. package/src/core.test.ts +325 -0
  38. package/src/doctor-contract.ts +9 -0
  39. package/src/doctor.test.ts +87 -0
  40. package/src/doctor.ts +40 -0
  41. package/src/gateway.ts +109 -0
  42. package/src/inbound.authz.test.ts +146 -0
  43. package/src/inbound.behavior.test.ts +309 -0
  44. package/src/inbound.ts +392 -0
  45. package/src/message-actions.test.ts +270 -0
  46. package/src/message-actions.ts +82 -0
  47. package/src/message-adapter.ts +28 -0
  48. package/src/monitor-runtime.ts +138 -0
  49. package/src/monitor.replay.test.ts +276 -0
  50. package/src/monitor.test-fixtures.ts +30 -0
  51. package/src/monitor.test-harness.ts +59 -0
  52. package/src/monitor.ts +385 -0
  53. package/src/normalize.ts +44 -0
  54. package/src/policy.ts +111 -0
  55. package/src/replay-guard.ts +128 -0
  56. package/src/room-info.test.ts +160 -0
  57. package/src/room-info.ts +130 -0
  58. package/src/runtime.ts +9 -0
  59. package/src/secret-contract.ts +103 -0
  60. package/src/secret-input.ts +4 -0
  61. package/src/send.cfg-threading.test.ts +359 -0
  62. package/src/send.runtime.ts +8 -0
  63. package/src/send.ts +269 -0
  64. package/src/session-route.ts +40 -0
  65. package/src/setup-core.ts +250 -0
  66. package/src/setup-surface.ts +195 -0
  67. package/src/setup.test.ts +445 -0
  68. package/src/signature.ts +82 -0
  69. package/src/types.ts +195 -0
  70. package/tsconfig.json +16 -0
  71. package/api.js +0 -7
  72. package/channel-plugin-api.js +0 -7
  73. package/contract-api.js +0 -7
  74. package/doctor-contract-api.js +0 -7
  75. package/index.js +0 -7
  76. package/runtime-api.js +0 -7
  77. package/secret-contract-api.js +0 -7
  78. package/setup-entry.js +0 -7
@@ -0,0 +1,82 @@
1
+ import {
2
+ jsonResult,
3
+ readStringParam,
4
+ resolveReactionMessageId,
5
+ } from "klaw/plugin-sdk/channel-actions";
6
+ import type {
7
+ ChannelMessageActionAdapter,
8
+ ChannelMessageActionName,
9
+ } from "klaw/plugin-sdk/channel-contract";
10
+ import { listNextcloudTalkAccountIds, resolveNextcloudTalkAccount } from "./accounts.js";
11
+ import { sendReactionNextcloudTalk } from "./send.js";
12
+ import type { CoreConfig } from "./types.js";
13
+
14
+ const providerId = "nextcloud-talk";
15
+
16
+ function isAccountConfigured(account: {
17
+ enabled: boolean;
18
+ secret: string | null;
19
+ baseUrl?: string | null;
20
+ }): boolean {
21
+ return Boolean(account.enabled && account.secret?.trim() && account.baseUrl?.trim());
22
+ }
23
+
24
+ function hasConfiguredAccount(cfg: CoreConfig, accountId: string | null | undefined): boolean {
25
+ if (accountId) {
26
+ const account = resolveNextcloudTalkAccount({ cfg, accountId });
27
+ return isAccountConfigured(account);
28
+ }
29
+ return listNextcloudTalkAccountIds(cfg)
30
+ .map((id) => resolveNextcloudTalkAccount({ cfg, accountId: id }))
31
+ .some(isAccountConfigured);
32
+ }
33
+
34
+ export const nextcloudTalkMessageActions: ChannelMessageActionAdapter = {
35
+ describeMessageTool: ({ cfg, accountId }) => {
36
+ if (!hasConfiguredAccount(cfg as CoreConfig, accountId)) {
37
+ return null;
38
+ }
39
+ const actions: ChannelMessageActionName[] = ["send", "react"];
40
+ return { actions };
41
+ },
42
+
43
+ supportsAction: ({ action }) => action !== "send",
44
+
45
+ handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
46
+ if (action === "send") {
47
+ throw new Error("Send should be handled by outbound, not actions handler.");
48
+ }
49
+
50
+ if (action === "react") {
51
+ const target = readStringParam(params, "to", {
52
+ required: true,
53
+ label: "to (room token)",
54
+ });
55
+
56
+ const messageIdRaw = resolveReactionMessageId({ args: params, toolContext });
57
+ if (messageIdRaw == null) {
58
+ throw new Error("messageId required");
59
+ }
60
+ const messageId = String(messageIdRaw);
61
+
62
+ const emoji = readStringParam(params, "emoji", { required: true });
63
+
64
+ // Reaction removal is part of the shared `react` tool contract but is not
65
+ // yet wired through to a Nextcloud Talk DELETE sender. Reject explicitly
66
+ // so callers do not get the opposite of what they requested.
67
+ if (params.remove === true) {
68
+ throw new Error(
69
+ "Nextcloud Talk reaction removal is not supported yet; only adding reactions is implemented.",
70
+ );
71
+ }
72
+
73
+ await sendReactionNextcloudTalk(target, messageId, emoji, {
74
+ accountId: accountId ?? undefined,
75
+ cfg: cfg as CoreConfig,
76
+ });
77
+ return jsonResult({ ok: true, added: emoji });
78
+ }
79
+
80
+ throw new Error(`Action ${action} not supported for ${providerId}.`);
81
+ },
82
+ };
@@ -0,0 +1,28 @@
1
+ import { defineChannelMessageAdapter } from "klaw/plugin-sdk/channel-message";
2
+ import { sendMessageNextcloudTalk } from "./send.js";
3
+ import type { CoreConfig } from "./types.js";
4
+
5
+ export const nextcloudTalkMessageAdapter = defineChannelMessageAdapter({
6
+ id: "nextcloud-talk",
7
+ durableFinal: {
8
+ capabilities: {
9
+ text: true,
10
+ media: true,
11
+ replyTo: true,
12
+ },
13
+ },
14
+ send: {
15
+ text: async ({ cfg, to, text, accountId, replyToId }) =>
16
+ await sendMessageNextcloudTalk(to, text, {
17
+ accountId: accountId ?? undefined,
18
+ replyTo: replyToId ?? undefined,
19
+ cfg: cfg as CoreConfig,
20
+ }),
21
+ media: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) =>
22
+ await sendMessageNextcloudTalk(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
23
+ accountId: accountId ?? undefined,
24
+ replyTo: replyToId ?? undefined,
25
+ cfg: cfg as CoreConfig,
26
+ }),
27
+ },
28
+ });
@@ -0,0 +1,138 @@
1
+ import os from "node:os";
2
+ import { resolveLoggerBackedRuntime } from "klaw/plugin-sdk/extension-shared";
3
+ import type { RuntimeEnv } from "klaw/plugin-sdk/runtime";
4
+ import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
5
+ import { resolveNextcloudTalkAccount } from "./accounts.js";
6
+ import { handleNextcloudTalkInbound } from "./inbound.js";
7
+ import {
8
+ createNextcloudTalkWebhookServer,
9
+ processNextcloudTalkReplayGuardedMessage,
10
+ } from "./monitor.js";
11
+ import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
12
+ import { getNextcloudTalkRuntime } from "./runtime.js";
13
+ import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
14
+
15
+ const DEFAULT_WEBHOOK_PORT = 8788;
16
+ const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
17
+ const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
18
+
19
+ function normalizeOrigin(value: string): string | null {
20
+ try {
21
+ return normalizeLowercaseStringOrEmpty(new URL(value).origin);
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ type NextcloudTalkMonitorOptions = {
28
+ accountId?: string;
29
+ config?: CoreConfig;
30
+ runtime?: RuntimeEnv;
31
+ abortSignal?: AbortSignal;
32
+ onMessage?: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
33
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
34
+ };
35
+
36
+ export async function monitorNextcloudTalkProvider(
37
+ opts: NextcloudTalkMonitorOptions,
38
+ ): Promise<{ stop: () => void }> {
39
+ const core = getNextcloudTalkRuntime();
40
+ const cfg = opts.config ?? (core.config.current() as CoreConfig);
41
+ const account = resolveNextcloudTalkAccount({
42
+ cfg,
43
+ accountId: opts.accountId,
44
+ });
45
+ const runtime: RuntimeEnv = resolveLoggerBackedRuntime(
46
+ opts.runtime,
47
+ core.logging.getChildLogger(),
48
+ );
49
+
50
+ if (!account.secret) {
51
+ throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`);
52
+ }
53
+
54
+ const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT;
55
+ const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST;
56
+ const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH;
57
+
58
+ const logger = core.logging.getChildLogger({
59
+ channel: "nextcloud-talk",
60
+ accountId: account.accountId,
61
+ });
62
+ const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
63
+ const replayGuard = createNextcloudTalkReplayGuard({
64
+ stateDir: core.state.resolveStateDir(process.env, os.homedir),
65
+ onDiskError: (error) => {
66
+ logger.warn(
67
+ `[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`,
68
+ );
69
+ },
70
+ });
71
+
72
+ const { start, stop } = createNextcloudTalkWebhookServer({
73
+ port,
74
+ host,
75
+ path,
76
+ secret: account.secret,
77
+ isBackendAllowed: (backend) => {
78
+ if (!expectedBackendOrigin) {
79
+ return true;
80
+ }
81
+ const backendOrigin = normalizeOrigin(backend);
82
+ return backendOrigin === expectedBackendOrigin;
83
+ },
84
+ processMessage: async (message) => {
85
+ const result = await processNextcloudTalkReplayGuardedMessage({
86
+ replayGuard,
87
+ accountId: account.accountId,
88
+ message,
89
+ handleMessage: async () => {
90
+ core.channel.activity.record({
91
+ channel: "nextcloud-talk",
92
+ accountId: account.accountId,
93
+ direction: "inbound",
94
+ at: message.timestamp,
95
+ });
96
+ if (opts.onMessage) {
97
+ await opts.onMessage(message);
98
+ } else {
99
+ await handleNextcloudTalkInbound({
100
+ message,
101
+ account,
102
+ config: cfg,
103
+ runtime,
104
+ statusSink: opts.statusSink,
105
+ });
106
+ }
107
+ },
108
+ });
109
+ if (result === "duplicate") {
110
+ logger.warn(
111
+ `[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`,
112
+ );
113
+ return;
114
+ }
115
+ },
116
+ onMessage: async () => {},
117
+ onError: (error) => {
118
+ logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
119
+ },
120
+ abortSignal: opts.abortSignal,
121
+ });
122
+
123
+ if (opts.abortSignal?.aborted) {
124
+ return { stop };
125
+ }
126
+ await start();
127
+ if (opts.abortSignal?.aborted) {
128
+ stop();
129
+ return { stop };
130
+ }
131
+
132
+ const publicUrl =
133
+ account.config.webhookPublicUrl ??
134
+ `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
135
+ logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`);
136
+
137
+ return { stop };
138
+ }
@@ -0,0 +1,276 @@
1
+ import { createMockIncomingRequest } from "klaw/plugin-sdk/test-env";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ NextcloudTalkRetryableWebhookError,
5
+ processNextcloudTalkReplayGuardedMessage,
6
+ readNextcloudTalkWebhookBody,
7
+ } from "./monitor.js";
8
+ import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js";
9
+ import { startWebhookServer } from "./monitor.test-harness.js";
10
+ import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
11
+ import { generateNextcloudTalkSignature } from "./signature.js";
12
+ import type { NextcloudTalkInboundMessage } from "./types.js";
13
+
14
+ describe("readNextcloudTalkWebhookBody", () => {
15
+ it("reads valid body within max bytes", async () => {
16
+ const req = createMockIncomingRequest(['{"type":"Create"}']);
17
+ const body = await readNextcloudTalkWebhookBody(req, 1024);
18
+ expect(body).toBe('{"type":"Create"}');
19
+ });
20
+
21
+ it("rejects when payload exceeds max bytes", async () => {
22
+ const req = createMockIncomingRequest(["x".repeat(300)]);
23
+ await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge");
24
+ });
25
+ });
26
+
27
+ describe("createNextcloudTalkWebhookServer auth order", () => {
28
+ it("rejects missing signature headers before reading request body", async () => {
29
+ const readBody = vi.fn(async () => {
30
+ throw new Error("should not be called for missing signature headers");
31
+ });
32
+ const harness = await startWebhookServer({
33
+ path: "/nextcloud-auth-order",
34
+ maxBodyBytes: 128,
35
+ readBody,
36
+ onMessage: vi.fn(),
37
+ });
38
+
39
+ const response = await fetch(harness.webhookUrl, {
40
+ method: "POST",
41
+ headers: {
42
+ "content-type": "application/json",
43
+ },
44
+ body: "{}",
45
+ });
46
+
47
+ expect(response.status).toBe(400);
48
+ expect(await response.json()).toEqual({ error: "Missing signature headers" });
49
+ expect(readBody).not.toHaveBeenCalled();
50
+ });
51
+ });
52
+
53
+ describe("createNextcloudTalkWebhookServer backend allowlist", () => {
54
+ it("rejects requests from unexpected backend origins", async () => {
55
+ const onMessage = vi.fn(async () => {});
56
+ const harness = await startWebhookServer({
57
+ path: "/nextcloud-backend-check",
58
+ isBackendAllowed: (backend) => backend === "https://nextcloud.expected",
59
+ onMessage,
60
+ });
61
+
62
+ const { body, headers } = createSignedCreateMessageRequest({
63
+ backend: "https://nextcloud.unexpected",
64
+ });
65
+ const response = await fetch(harness.webhookUrl, {
66
+ method: "POST",
67
+ headers,
68
+ body,
69
+ });
70
+
71
+ expect(response.status).toBe(401);
72
+ expect(await response.json()).toEqual({ error: "Invalid backend" });
73
+ expect(onMessage).not.toHaveBeenCalled();
74
+ });
75
+ });
76
+
77
+ describe("createNextcloudTalkWebhookServer replay handling", () => {
78
+ function createReplayGuardedProcess(params: {
79
+ stateDir?: string;
80
+ accountId?: string;
81
+ handleMessage: () => Promise<void>;
82
+ }) {
83
+ const replayGuard = createNextcloudTalkReplayGuard(
84
+ params.stateDir ? { stateDir: params.stateDir } : {},
85
+ );
86
+
87
+ return (message: NextcloudTalkInboundMessage) =>
88
+ processNextcloudTalkReplayGuardedMessage({
89
+ replayGuard,
90
+ accountId: params.accountId ?? "acct",
91
+ message,
92
+ handleMessage: params.handleMessage,
93
+ });
94
+ }
95
+
96
+ function buildInboundMessage(): NextcloudTalkInboundMessage {
97
+ return {
98
+ messageId: "msg-1",
99
+ roomToken: "room-token",
100
+ roomName: "Room 1",
101
+ senderId: "alice",
102
+ senderName: "Alice",
103
+ text: "hello",
104
+ mediaType: "text/plain",
105
+ timestamp: 1_700_000_000_000,
106
+ isGroupChat: true,
107
+ };
108
+ }
109
+
110
+ it("acknowledges replayed requests and skips onMessage side effects", async () => {
111
+ const seen = new Set<string>();
112
+ const onMessage = vi.fn(async () => {});
113
+ const shouldProcessMessage = vi.fn(async (message: NextcloudTalkInboundMessage) => {
114
+ if (seen.has(message.messageId)) {
115
+ return false;
116
+ }
117
+ seen.add(message.messageId);
118
+ return true;
119
+ });
120
+ const harness = await startWebhookServer({
121
+ path: "/nextcloud-replay",
122
+ shouldProcessMessage,
123
+ onMessage,
124
+ });
125
+
126
+ const { body, headers } = createSignedCreateMessageRequest();
127
+
128
+ const first = await fetch(harness.webhookUrl, {
129
+ method: "POST",
130
+ headers,
131
+ body,
132
+ });
133
+ const second = await fetch(harness.webhookUrl, {
134
+ method: "POST",
135
+ headers,
136
+ body,
137
+ });
138
+
139
+ expect(first.status).toBe(200);
140
+ expect(second.status).toBe(200);
141
+ expect(shouldProcessMessage).toHaveBeenCalledTimes(2);
142
+ expect(onMessage).toHaveBeenCalledTimes(1);
143
+ });
144
+
145
+ it("allows a retry after replay-guarded processing fails before commit", async () => {
146
+ let attempts = 0;
147
+ const handleMessage = vi.fn(async () => {
148
+ attempts += 1;
149
+ if (attempts === 1) {
150
+ throw new NextcloudTalkRetryableWebhookError("transient nextcloud failure");
151
+ }
152
+ });
153
+ const processMessage = createReplayGuardedProcess({
154
+ handleMessage,
155
+ });
156
+ const message = buildInboundMessage();
157
+
158
+ await expect(processMessage(message)).rejects.toThrow("transient nextcloud failure");
159
+ await expect(processMessage(message)).resolves.toBe("processed");
160
+
161
+ expect(handleMessage).toHaveBeenCalledTimes(2);
162
+ });
163
+
164
+ it("keeps replay committed after a non-retryable replay-guarded processing failure", async () => {
165
+ const visibleSideEffect = vi.fn();
166
+ const handleMessage = vi.fn(async () => {
167
+ visibleSideEffect();
168
+ throw new Error("post-send failure");
169
+ });
170
+ const processMessage = createReplayGuardedProcess({
171
+ handleMessage,
172
+ });
173
+ const message = buildInboundMessage();
174
+
175
+ await expect(processMessage(message)).rejects.toThrow("post-send failure");
176
+ await expect(processMessage(message)).resolves.toBe("duplicate");
177
+
178
+ expect(handleMessage).toHaveBeenCalledTimes(1);
179
+ expect(visibleSideEffect).toHaveBeenCalledTimes(1);
180
+ });
181
+ });
182
+
183
+ describe("createNextcloudTalkWebhookServer payload validation", () => {
184
+ it("rejects malformed webhook payloads after signature verification", async () => {
185
+ const payload = {
186
+ type: "Create",
187
+ actor: { type: "Person", id: "alice", name: "Alice" },
188
+ object: {
189
+ type: "Note",
190
+ id: "msg-1",
191
+ name: "hello",
192
+ content: "hello",
193
+ mediaType: "text/plain",
194
+ },
195
+ target: { type: "Collection", id: "", name: "Room 1" },
196
+ };
197
+ const body = JSON.stringify(payload);
198
+ const { random, signature } = generateNextcloudTalkSignature({
199
+ body,
200
+ secret: "nextcloud-secret", // pragma: allowlist secret
201
+ });
202
+ const harness = await startWebhookServer({
203
+ path: "/nextcloud-invalid-payload",
204
+ onMessage: vi.fn(),
205
+ });
206
+
207
+ const response = await fetch(harness.webhookUrl, {
208
+ method: "POST",
209
+ headers: {
210
+ "content-type": "application/json",
211
+ "x-nextcloud-talk-random": random,
212
+ "x-nextcloud-talk-signature": signature,
213
+ "x-nextcloud-talk-backend": "https://nextcloud.example",
214
+ },
215
+ body,
216
+ });
217
+
218
+ expect(response.status).toBe(400);
219
+ expect(await response.json()).toEqual({ error: "Invalid payload format" });
220
+ });
221
+ });
222
+
223
+ describe("createNextcloudTalkWebhookServer auth rate limiting", () => {
224
+ it("rate limits repeated invalid signature attempts from the same source", async () => {
225
+ const maxRequests = 1;
226
+ const harness = await startWebhookServer({
227
+ path: "/nextcloud-auth-rate-limit",
228
+ authRateLimit: { maxRequests },
229
+ onMessage: vi.fn(),
230
+ });
231
+ const { body, headers } = createSignedCreateMessageRequest();
232
+ const invalidHeaders = {
233
+ ...headers,
234
+ "x-nextcloud-talk-signature": "invalid-signature",
235
+ };
236
+
237
+ let firstResponse: Response | undefined;
238
+ let lastResponse: Response | undefined;
239
+ for (let attempt = 0; attempt <= maxRequests; attempt += 1) {
240
+ const response = await fetch(harness.webhookUrl, {
241
+ method: "POST",
242
+ headers: invalidHeaders,
243
+ body,
244
+ });
245
+ if (attempt === 0) {
246
+ firstResponse = response;
247
+ }
248
+ lastResponse = response;
249
+ }
250
+
251
+ expect(firstResponse?.status).toBe(401);
252
+ expect(lastResponse?.status).toBe(429);
253
+ expect(await lastResponse?.text()).toBe("Too Many Requests");
254
+ });
255
+
256
+ it("does not rate limit valid signed webhook bursts from the same source", async () => {
257
+ const maxRequests = 1;
258
+ const harness = await startWebhookServer({
259
+ path: "/nextcloud-auth-rate-limit-valid",
260
+ authRateLimit: { maxRequests },
261
+ onMessage: vi.fn(),
262
+ });
263
+ const { body, headers } = createSignedCreateMessageRequest();
264
+
265
+ let lastResponse: Response | undefined;
266
+ for (let attempt = 0; attempt <= maxRequests; attempt += 1) {
267
+ lastResponse = await fetch(harness.webhookUrl, {
268
+ method: "POST",
269
+ headers,
270
+ body,
271
+ });
272
+ }
273
+
274
+ expect(lastResponse?.status).toBe(200);
275
+ });
276
+ });
@@ -0,0 +1,30 @@
1
+ import { generateNextcloudTalkSignature } from "./signature.js";
2
+
3
+ export function createSignedCreateMessageRequest(params?: { backend?: string }) {
4
+ const payload = {
5
+ type: "Create",
6
+ actor: { type: "Person", id: "alice", name: "Alice" },
7
+ object: {
8
+ type: "Note",
9
+ id: "msg-1",
10
+ name: "hello",
11
+ content: "hello",
12
+ mediaType: "text/plain",
13
+ },
14
+ target: { type: "Collection", id: "room-1", name: "Room 1" },
15
+ };
16
+ const body = JSON.stringify(payload);
17
+ const { random, signature } = generateNextcloudTalkSignature({
18
+ body,
19
+ secret: "nextcloud-secret", // pragma: allowlist secret
20
+ });
21
+ return {
22
+ body,
23
+ headers: {
24
+ "content-type": "application/json",
25
+ "x-nextcloud-talk-random": random,
26
+ "x-nextcloud-talk-signature": signature,
27
+ "x-nextcloud-talk-backend": params?.backend ?? "https://nextcloud.example",
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,59 @@
1
+ import { type AddressInfo } from "node:net";
2
+ import { afterEach } from "vitest";
3
+ import { createNextcloudTalkWebhookServer } from "./monitor.js";
4
+ import type { NextcloudTalkWebhookServerOptions } from "./types.js";
5
+
6
+ type WebhookHarness = {
7
+ webhookUrl: string;
8
+ stop: () => Promise<void>;
9
+ };
10
+
11
+ const cleanupFns: Array<() => Promise<void>> = [];
12
+
13
+ afterEach(async () => {
14
+ while (cleanupFns.length > 0) {
15
+ const cleanup = cleanupFns.pop();
16
+ if (cleanup) {
17
+ await cleanup();
18
+ }
19
+ }
20
+ });
21
+
22
+ type StartWebhookServerParams = Omit<
23
+ NextcloudTalkWebhookServerOptions,
24
+ "port" | "host" | "path" | "secret"
25
+ > & {
26
+ path: string;
27
+ secret?: string;
28
+ host?: string;
29
+ port?: number;
30
+ };
31
+
32
+ export async function startWebhookServer(
33
+ params: StartWebhookServerParams,
34
+ ): Promise<WebhookHarness> {
35
+ const host = params.host ?? "127.0.0.1";
36
+ const port = params.port ?? 0;
37
+ const secret = params.secret ?? "nextcloud-secret";
38
+ const { server, start } = createNextcloudTalkWebhookServer({
39
+ ...params,
40
+ port,
41
+ host,
42
+ secret,
43
+ });
44
+ await start();
45
+ const address = server.address() as AddressInfo | null;
46
+ if (!address) {
47
+ throw new Error("missing server address");
48
+ }
49
+
50
+ const harness: WebhookHarness = {
51
+ webhookUrl: `http://${host}:${address.port}${params.path}`,
52
+ stop: () =>
53
+ new Promise<void>((resolve) => {
54
+ server.close(() => resolve());
55
+ }),
56
+ };
57
+ cleanupFns.push(harness.stop);
58
+ return harness;
59
+ }