@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,325 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ looksLikeNextcloudTalkTargetId,
7
+ normalizeNextcloudTalkMessagingTarget,
8
+ stripNextcloudTalkTargetPrefix,
9
+ } from "./normalize.js";
10
+ import { resolveNextcloudTalkAllowlistMatch } from "./policy.js";
11
+ import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
12
+ import { resolveNextcloudTalkOutboundSessionRoute } from "./session-route.js";
13
+ import {
14
+ extractNextcloudTalkHeaders,
15
+ generateNextcloudTalkSignature,
16
+ verifyNextcloudTalkSignature,
17
+ } from "./signature.js";
18
+
19
+ const tempDirs: string[] = [];
20
+
21
+ afterEach(async () => {
22
+ while (tempDirs.length > 0) {
23
+ const dir = tempDirs.pop();
24
+ if (dir) {
25
+ await rm(dir, { recursive: true, force: true });
26
+ }
27
+ }
28
+ });
29
+
30
+ async function makeTempDir(): Promise<string> {
31
+ const dir = await mkdtemp(path.join(os.tmpdir(), "nextcloud-talk-replay-"));
32
+ tempDirs.push(dir);
33
+ return dir;
34
+ }
35
+
36
+ function requireFirstTimingSafeEqualCall(mock: ReturnType<typeof vi.fn>): [unknown, unknown] {
37
+ const [call] = mock.mock.calls;
38
+ if (!call) {
39
+ throw new Error("expected timingSafeEqual call");
40
+ }
41
+ return call as [unknown, unknown];
42
+ }
43
+
44
+ describe("nextcloud talk core", () => {
45
+ it("builds an outbound session route for normalized room targets", () => {
46
+ const route = resolveNextcloudTalkOutboundSessionRoute({
47
+ cfg: {},
48
+ agentId: "main",
49
+ accountId: "acct-1",
50
+ target: "nextcloud-talk:room-123",
51
+ });
52
+
53
+ expect(route).toEqual({
54
+ sessionKey: "agent:main:nextcloud-talk:group:room-123",
55
+ baseSessionKey: "agent:main:nextcloud-talk:group:room-123",
56
+ peer: {
57
+ kind: "group",
58
+ id: "room-123",
59
+ },
60
+ chatType: "group",
61
+ from: "nextcloud-talk:room:room-123",
62
+ to: "nextcloud-talk:room-123",
63
+ });
64
+ });
65
+
66
+ it("returns null when the target cannot be normalized to a room id", () => {
67
+ expect(
68
+ resolveNextcloudTalkOutboundSessionRoute({
69
+ cfg: {},
70
+ agentId: "main",
71
+ accountId: "acct-1",
72
+ target: "",
73
+ }),
74
+ ).toBeNull();
75
+ });
76
+
77
+ it("normalizes and recognizes supported room target formats", () => {
78
+ expect(stripNextcloudTalkTargetPrefix(" room:abc123 ")).toBe("abc123");
79
+ expect(stripNextcloudTalkTargetPrefix("nextcloud-talk:room:AbC123")).toBe("AbC123");
80
+ expect(stripNextcloudTalkTargetPrefix("nc-talk:room:ops")).toBe("ops");
81
+ expect(stripNextcloudTalkTargetPrefix("nc:room:ops")).toBe("ops");
82
+ expect(stripNextcloudTalkTargetPrefix("room: ")).toBeUndefined();
83
+
84
+ expect(normalizeNextcloudTalkMessagingTarget("room:AbC123")).toBe("nextcloud-talk:abc123");
85
+ expect(normalizeNextcloudTalkMessagingTarget("nc-talk:room:Ops")).toBe("nextcloud-talk:ops");
86
+
87
+ expect(looksLikeNextcloudTalkTargetId("nextcloud-talk:room:abc12345")).toBe(true);
88
+ expect(looksLikeNextcloudTalkTargetId("nc:opsroom1")).toBe(true);
89
+ expect(looksLikeNextcloudTalkTargetId("abc12345")).toBe(true);
90
+ expect(looksLikeNextcloudTalkTargetId("")).toBe(false);
91
+ });
92
+
93
+ it("verifies generated signatures and extracts normalized headers", () => {
94
+ const body = JSON.stringify({ hello: "world" });
95
+ const generated = generateNextcloudTalkSignature({
96
+ body,
97
+ secret: "secret-123",
98
+ });
99
+
100
+ expect(generated.random).toMatch(/^[0-9a-f]{64}$/);
101
+ expect(generated.signature).toMatch(/^[0-9a-f]{64}$/);
102
+ expect(
103
+ verifyNextcloudTalkSignature({
104
+ signature: generated.signature,
105
+ random: generated.random,
106
+ body,
107
+ secret: "secret-123",
108
+ }),
109
+ ).toBe(true);
110
+ expect(
111
+ verifyNextcloudTalkSignature({
112
+ signature: "",
113
+ random: "abc",
114
+ body: "body",
115
+ secret: "secret",
116
+ }),
117
+ ).toBe(false);
118
+ expect(
119
+ verifyNextcloudTalkSignature({
120
+ signature: "deadbeef",
121
+ random: "abc",
122
+ body: "body",
123
+ secret: "secret",
124
+ }),
125
+ ).toBe(false);
126
+
127
+ expect(
128
+ extractNextcloudTalkHeaders({
129
+ "x-nextcloud-talk-signature": "sig",
130
+ "x-nextcloud-talk-random": "rand",
131
+ "x-nextcloud-talk-backend": "backend",
132
+ }),
133
+ ).toEqual({
134
+ signature: "sig",
135
+ random: "rand",
136
+ backend: "backend",
137
+ });
138
+ expect(
139
+ extractNextcloudTalkHeaders({
140
+ "X-Nextcloud-Talk-Signature": "sig",
141
+ }),
142
+ ).toBeNull();
143
+ });
144
+
145
+ it("rejects tampered bodies, wrong secrets, and tampered signatures", () => {
146
+ const body = JSON.stringify({ hello: "world" });
147
+ const generated = generateNextcloudTalkSignature({
148
+ body,
149
+ secret: "secret-123",
150
+ });
151
+
152
+ expect(
153
+ verifyNextcloudTalkSignature({
154
+ signature: generated.signature,
155
+ random: generated.random,
156
+ body: JSON.stringify({ hello: "tampered" }),
157
+ secret: "secret-123",
158
+ }),
159
+ ).toBe(false);
160
+ expect(
161
+ verifyNextcloudTalkSignature({
162
+ signature: generated.signature,
163
+ random: generated.random,
164
+ body,
165
+ secret: "wrong-secret",
166
+ }),
167
+ ).toBe(false);
168
+ expect(
169
+ verifyNextcloudTalkSignature({
170
+ signature: "a".repeat(generated.signature.length),
171
+ random: generated.random,
172
+ body,
173
+ secret: "secret-123",
174
+ }),
175
+ ).toBe(false);
176
+ });
177
+
178
+ it("takes the first value from array-backed headers", () => {
179
+ expect(
180
+ extractNextcloudTalkHeaders({
181
+ "x-nextcloud-talk-signature": ["sig1", "sig2"],
182
+ "x-nextcloud-talk-random": ["rand1", "rand2"],
183
+ "x-nextcloud-talk-backend": ["backend1", "backend2"],
184
+ }),
185
+ ).toEqual({
186
+ signature: "sig1",
187
+ random: "rand1",
188
+ backend: "backend1",
189
+ });
190
+ });
191
+
192
+ it("still runs timingSafeEqual when the supplied signature length mismatches", async () => {
193
+ const timingSafeEqualMock = vi.fn();
194
+
195
+ vi.resetModules();
196
+ vi.doMock("node:crypto", async (importOriginal) => {
197
+ const actual = await importOriginal<typeof import("node:crypto")>();
198
+ return {
199
+ ...actual,
200
+ timingSafeEqual: vi.fn((left: NodeJS.ArrayBufferView, right: NodeJS.ArrayBufferView) => {
201
+ timingSafeEqualMock(left, right);
202
+ return actual.timingSafeEqual(left, right);
203
+ }),
204
+ };
205
+ });
206
+
207
+ try {
208
+ const { generateNextcloudTalkSignature, verifyNextcloudTalkSignature } =
209
+ await import("./signature.js");
210
+ const body = JSON.stringify({ hello: "world" });
211
+ const generated = generateNextcloudTalkSignature({
212
+ body,
213
+ secret: "secret-123",
214
+ });
215
+ const shortSignature = generated.signature.slice(0, 12);
216
+
217
+ expect(
218
+ verifyNextcloudTalkSignature({
219
+ signature: shortSignature,
220
+ random: generated.random,
221
+ body,
222
+ secret: "secret-123",
223
+ }),
224
+ ).toBe(false);
225
+
226
+ expect(timingSafeEqualMock).toHaveBeenCalledOnce();
227
+ const [leftBuffer, rightBuffer] = requireFirstTimingSafeEqualCall(timingSafeEqualMock);
228
+ expect(Buffer.isBuffer(leftBuffer)).toBe(true);
229
+ expect(Buffer.isBuffer(rightBuffer)).toBe(true);
230
+ if (!Buffer.isBuffer(leftBuffer) || !Buffer.isBuffer(rightBuffer)) {
231
+ throw new TypeError("Expected timingSafeEqual to receive Buffer arguments");
232
+ }
233
+ expect(leftBuffer).toHaveLength(rightBuffer.length);
234
+ } finally {
235
+ vi.doUnmock("node:crypto");
236
+ vi.resetModules();
237
+ }
238
+ });
239
+
240
+ it("persists replay decisions across guard instances and scopes account namespaces", async () => {
241
+ const stateDir = await makeTempDir();
242
+
243
+ const firstGuard = createNextcloudTalkReplayGuard({ stateDir });
244
+ const firstAttempt = await firstGuard.shouldProcessMessage({
245
+ accountId: "account-a",
246
+ roomToken: "room-1",
247
+ messageId: "msg-1",
248
+ });
249
+ const replayAttempt = await firstGuard.shouldProcessMessage({
250
+ accountId: "account-a",
251
+ roomToken: "room-1",
252
+ messageId: "msg-1",
253
+ });
254
+
255
+ const secondGuard = createNextcloudTalkReplayGuard({ stateDir });
256
+ const restartReplayAttempt = await secondGuard.shouldProcessMessage({
257
+ accountId: "account-a",
258
+ roomToken: "room-1",
259
+ messageId: "msg-1",
260
+ });
261
+ const otherAccountFirstAttempt = await secondGuard.shouldProcessMessage({
262
+ accountId: "account-b",
263
+ roomToken: "room-1",
264
+ messageId: "msg-1",
265
+ });
266
+
267
+ expect(firstAttempt).toBe(true);
268
+ expect(replayAttempt).toBe(false);
269
+ expect(restartReplayAttempt).toBe(false);
270
+ expect(otherAccountFirstAttempt).toBe(true);
271
+ });
272
+
273
+ it("releases in-flight replay claims when processing fails", async () => {
274
+ const guard = createNextcloudTalkReplayGuard({});
275
+
276
+ const firstClaim = await guard.claimMessage({
277
+ accountId: "account-a",
278
+ roomToken: "room-1",
279
+ messageId: "msg-claim",
280
+ });
281
+ const secondClaim = await guard.claimMessage({
282
+ accountId: "account-a",
283
+ roomToken: "room-1",
284
+ messageId: "msg-claim",
285
+ });
286
+
287
+ expect(firstClaim).toBe("claimed");
288
+ expect(secondClaim).toBe("inflight");
289
+
290
+ guard.releaseMessage({
291
+ accountId: "account-a",
292
+ roomToken: "room-1",
293
+ messageId: "msg-claim",
294
+ error: new Error("transient"),
295
+ });
296
+
297
+ const retryClaim = await guard.claimMessage({
298
+ accountId: "account-a",
299
+ roomToken: "room-1",
300
+ messageId: "msg-claim",
301
+ });
302
+ expect(retryClaim).toBe("claimed");
303
+ });
304
+
305
+ it("resolves allowlist matches", () => {
306
+ expect(
307
+ resolveNextcloudTalkAllowlistMatch({
308
+ allowFrom: ["*"],
309
+ senderId: "user-id",
310
+ }).allowed,
311
+ ).toBe(true);
312
+ expect(
313
+ resolveNextcloudTalkAllowlistMatch({
314
+ allowFrom: ["nc:User-Id"],
315
+ senderId: "user-id",
316
+ }),
317
+ ).toEqual({ allowed: true, matchKey: "user-id", matchSource: "id" });
318
+ expect(
319
+ resolveNextcloudTalkAllowlistMatch({
320
+ allowFrom: ["allowed"],
321
+ senderId: "other",
322
+ }).allowed,
323
+ ).toBe(false);
324
+ });
325
+ });
@@ -0,0 +1,9 @@
1
+ import { createLegacyPrivateNetworkDoctorContract } from "klaw/plugin-sdk/ssrf-runtime";
2
+
3
+ const contract = createLegacyPrivateNetworkDoctorContract({
4
+ channelKey: "nextcloud-talk",
5
+ });
6
+
7
+ export const legacyConfigRules = contract.legacyConfigRules;
8
+
9
+ export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;
@@ -0,0 +1,87 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const hoisted = vi.hoisted(() => ({
4
+ probeNextcloudTalkBotResponseFeature: vi.fn(),
5
+ }));
6
+
7
+ vi.mock("./bot-preflight.js", () => ({
8
+ probeNextcloudTalkBotResponseFeature: hoisted.probeNextcloudTalkBotResponseFeature,
9
+ }));
10
+
11
+ const { nextcloudTalkDoctor } = await import("./doctor.js");
12
+
13
+ function getNextcloudTalkCompatibilityNormalizer(): NonNullable<
14
+ typeof nextcloudTalkDoctor.normalizeCompatibilityConfig
15
+ > {
16
+ const normalize = nextcloudTalkDoctor.normalizeCompatibilityConfig;
17
+ if (!normalize) {
18
+ throw new Error("Expected nextcloud-talk doctor to expose normalizeCompatibilityConfig");
19
+ }
20
+ return normalize;
21
+ }
22
+
23
+ describe("nextcloud-talk doctor", () => {
24
+ beforeEach(() => {
25
+ hoisted.probeNextcloudTalkBotResponseFeature.mockReset();
26
+ });
27
+
28
+ it("normalizes legacy private-network aliases", () => {
29
+ const normalize = getNextcloudTalkCompatibilityNormalizer();
30
+
31
+ const result = normalize({
32
+ cfg: {
33
+ channels: {
34
+ "nextcloud-talk": {
35
+ allowPrivateNetwork: true,
36
+ accounts: {
37
+ work: {
38
+ allowPrivateNetwork: false,
39
+ },
40
+ },
41
+ },
42
+ },
43
+ } as never,
44
+ });
45
+
46
+ expect(result.config.channels?.["nextcloud-talk"]?.network).toEqual({
47
+ dangerouslyAllowPrivateNetwork: true,
48
+ });
49
+ expect(
50
+ (
51
+ result.config.channels?.["nextcloud-talk"]?.accounts?.work as
52
+ | { network?: Record<string, unknown> }
53
+ | undefined
54
+ )?.network,
55
+ ).toEqual({
56
+ dangerouslyAllowPrivateNetwork: false,
57
+ });
58
+ });
59
+
60
+ it("warns when the configured bot is missing the response feature", async () => {
61
+ hoisted.probeNextcloudTalkBotResponseFeature.mockResolvedValueOnce({
62
+ ok: false,
63
+ code: "missing_response_feature",
64
+ message:
65
+ 'Nextcloud Talk bot "Klaw" (1) is missing the response feature (features=9); outbound replies will fail.',
66
+ });
67
+
68
+ await expect(
69
+ nextcloudTalkDoctor.collectPreviewWarnings?.({
70
+ cfg: {
71
+ channels: {
72
+ "nextcloud-talk": {
73
+ baseUrl: "https://cloud.example.com",
74
+ botSecret: "secret",
75
+ apiUser: "admin",
76
+ apiPassword: "app-password",
77
+ webhookPublicUrl: "https://gateway.example.com/nextcloud-talk-webhook",
78
+ },
79
+ },
80
+ } as never,
81
+ doctorFixCommand: "klaw doctor --fix",
82
+ }),
83
+ ).resolves.toEqual([
84
+ '- channels.nextcloud-talk.default: Nextcloud Talk bot "Klaw" (1) is missing the response feature (features=9); outbound replies will fail.',
85
+ ]);
86
+ });
87
+ });
package/src/doctor.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { ChannelDoctorAdapter } from "klaw/plugin-sdk/channel-contract";
2
+ import { listNextcloudTalkAccountIds, resolveNextcloudTalkAccount } from "./accounts.js";
3
+ import { probeNextcloudTalkBotResponseFeature } from "./bot-preflight.js";
4
+ import {
5
+ legacyConfigRules as NEXTCLOUD_TALK_LEGACY_CONFIG_RULES,
6
+ normalizeCompatibilityConfig as normalizeNextcloudTalkCompatibilityConfig,
7
+ } from "./doctor-contract.js";
8
+ import type { CoreConfig } from "./types.js";
9
+
10
+ async function collectNextcloudTalkBotResponseWarnings(params: {
11
+ cfg: CoreConfig;
12
+ }): Promise<string[]> {
13
+ const warnings: string[] = [];
14
+ for (const accountId of listNextcloudTalkAccountIds(params.cfg)) {
15
+ const account = resolveNextcloudTalkAccount({ cfg: params.cfg, accountId });
16
+ if (!account.enabled || !account.secret || !account.baseUrl) {
17
+ continue;
18
+ }
19
+ const result = await probeNextcloudTalkBotResponseFeature({
20
+ account,
21
+ timeoutMs: 5_000,
22
+ });
23
+ if (
24
+ result.code === "missing_response_feature" ||
25
+ result.code === "bot_not_found" ||
26
+ result.code === "api_error" ||
27
+ result.code === "request_failed"
28
+ ) {
29
+ warnings.push(`- channels.nextcloud-talk.${account.accountId}: ${result.message}`);
30
+ }
31
+ }
32
+ return warnings;
33
+ }
34
+
35
+ export const nextcloudTalkDoctor: ChannelDoctorAdapter = {
36
+ legacyConfigRules: NEXTCLOUD_TALK_LEGACY_CONFIG_RULES,
37
+ normalizeCompatibilityConfig: normalizeNextcloudTalkCompatibilityConfig,
38
+ collectPreviewWarnings: async ({ cfg }) =>
39
+ await collectNextcloudTalkBotResponseWarnings({ cfg: cfg as CoreConfig }),
40
+ };
package/src/gateway.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { createAccountStatusSink } from "klaw/plugin-sdk/channel-lifecycle";
2
+ import { runStoppablePassiveMonitor } from "klaw/plugin-sdk/extension-shared";
3
+ import { resolveNextcloudTalkAccount, type ResolvedNextcloudTalkAccount } from "./accounts.js";
4
+ import {
5
+ clearAccountEntryFields,
6
+ DEFAULT_ACCOUNT_ID,
7
+ type ChannelPlugin,
8
+ type KlawConfig,
9
+ } from "./channel-api.js";
10
+ import { monitorNextcloudTalkProvider } from "./monitor-runtime.js";
11
+ import { getNextcloudTalkRuntime } from "./runtime.js";
12
+ import type { CoreConfig } from "./types.js";
13
+
14
+ export const nextcloudTalkGatewayAdapter: NonNullable<
15
+ ChannelPlugin<ResolvedNextcloudTalkAccount>["gateway"]
16
+ > = {
17
+ startAccount: async (ctx) => {
18
+ const account = ctx.account;
19
+ if (!account.secret || !account.baseUrl) {
20
+ throw new Error(
21
+ `Nextcloud Talk not configured for account "${account.accountId}" (missing secret or baseUrl)`,
22
+ );
23
+ }
24
+
25
+ ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`);
26
+
27
+ const statusSink = createAccountStatusSink({
28
+ accountId: ctx.accountId,
29
+ setStatus: ctx.setStatus,
30
+ });
31
+
32
+ await runStoppablePassiveMonitor({
33
+ abortSignal: ctx.abortSignal,
34
+ start: async () =>
35
+ await monitorNextcloudTalkProvider({
36
+ accountId: account.accountId,
37
+ config: ctx.cfg as CoreConfig,
38
+ runtime: ctx.runtime,
39
+ abortSignal: ctx.abortSignal,
40
+ statusSink,
41
+ }),
42
+ });
43
+ },
44
+ logoutAccount: async ({ accountId, cfg }) => {
45
+ const nextCfg = { ...cfg } as KlawConfig;
46
+ const nextSection = cfg.channels?.["nextcloud-talk"]
47
+ ? { ...cfg.channels["nextcloud-talk"] }
48
+ : undefined;
49
+ let cleared = false;
50
+ let changed = false;
51
+
52
+ if (nextSection) {
53
+ if (accountId === DEFAULT_ACCOUNT_ID && nextSection.botSecret) {
54
+ delete nextSection.botSecret;
55
+ cleared = true;
56
+ changed = true;
57
+ }
58
+ const accountCleanup = clearAccountEntryFields({
59
+ accounts: nextSection.accounts as Record<string, object> | undefined,
60
+ accountId,
61
+ fields: ["botSecret"],
62
+ });
63
+ if (accountCleanup.changed) {
64
+ changed = true;
65
+ if (accountCleanup.cleared) {
66
+ cleared = true;
67
+ }
68
+ if (accountCleanup.nextAccounts) {
69
+ nextSection.accounts = accountCleanup.nextAccounts as Record<string, unknown>;
70
+ } else {
71
+ delete nextSection.accounts;
72
+ }
73
+ }
74
+ }
75
+
76
+ if (changed) {
77
+ if (nextSection && Object.keys(nextSection).length > 0) {
78
+ nextCfg.channels = { ...nextCfg.channels, "nextcloud-talk": nextSection };
79
+ } else {
80
+ const nextChannels = { ...nextCfg.channels } as Record<string, unknown>;
81
+ delete nextChannels["nextcloud-talk"];
82
+ if (Object.keys(nextChannels).length > 0) {
83
+ nextCfg.channels = nextChannels as KlawConfig["channels"];
84
+ } else {
85
+ delete nextCfg.channels;
86
+ }
87
+ }
88
+ }
89
+
90
+ const resolved = resolveNextcloudTalkAccount({
91
+ cfg: changed ? (nextCfg as CoreConfig) : (cfg as CoreConfig),
92
+ accountId,
93
+ });
94
+ const loggedOut = resolved.secretSource === "none";
95
+
96
+ if (changed) {
97
+ await getNextcloudTalkRuntime().config.replaceConfigFile({
98
+ nextConfig: nextCfg,
99
+ afterWrite: { mode: "auto" },
100
+ });
101
+ }
102
+
103
+ return {
104
+ cleared,
105
+ envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
106
+ loggedOut,
107
+ };
108
+ },
109
+ };