@kodelyth/nextcloud-talk 2026.5.42 → 2026.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/klaw.plugin.json +799 -2
  2. package/package.json +16 -4
  3. package/api.ts +0 -1
  4. package/channel-plugin-api.ts +0 -1
  5. package/contract-api.ts +0 -4
  6. package/doctor-contract-api.ts +0 -1
  7. package/index.ts +0 -20
  8. package/runtime-api.ts +0 -29
  9. package/secret-contract-api.ts +0 -5
  10. package/setup-entry.ts +0 -13
  11. package/src/accounts.test.ts +0 -31
  12. package/src/accounts.ts +0 -149
  13. package/src/api-credentials.ts +0 -31
  14. package/src/approval-auth.test.ts +0 -17
  15. package/src/approval-auth.ts +0 -27
  16. package/src/bot-preflight.test.ts +0 -135
  17. package/src/bot-preflight.ts +0 -183
  18. package/src/channel-api.ts +0 -5
  19. package/src/channel.adapters.ts +0 -52
  20. package/src/channel.core.test.ts +0 -75
  21. package/src/channel.lifecycle.test.ts +0 -91
  22. package/src/channel.status.test.ts +0 -28
  23. package/src/channel.ts +0 -225
  24. package/src/config-schema.ts +0 -79
  25. package/src/core.test.ts +0 -325
  26. package/src/doctor-contract.ts +0 -9
  27. package/src/doctor.test.ts +0 -87
  28. package/src/doctor.ts +0 -40
  29. package/src/gateway.ts +0 -109
  30. package/src/inbound.authz.test.ts +0 -146
  31. package/src/inbound.behavior.test.ts +0 -309
  32. package/src/inbound.ts +0 -392
  33. package/src/message-actions.test.ts +0 -270
  34. package/src/message-actions.ts +0 -82
  35. package/src/message-adapter.ts +0 -28
  36. package/src/monitor-runtime.ts +0 -138
  37. package/src/monitor.replay.test.ts +0 -276
  38. package/src/monitor.test-fixtures.ts +0 -30
  39. package/src/monitor.test-harness.ts +0 -59
  40. package/src/monitor.ts +0 -385
  41. package/src/normalize.ts +0 -44
  42. package/src/policy.ts +0 -111
  43. package/src/replay-guard.ts +0 -128
  44. package/src/room-info.test.ts +0 -160
  45. package/src/room-info.ts +0 -130
  46. package/src/runtime.ts +0 -9
  47. package/src/secret-contract.ts +0 -103
  48. package/src/secret-input.ts +0 -4
  49. package/src/send.cfg-threading.test.ts +0 -359
  50. package/src/send.runtime.ts +0 -8
  51. package/src/send.ts +0 -269
  52. package/src/session-route.ts +0 -40
  53. package/src/setup-core.ts +0 -250
  54. package/src/setup-surface.ts +0 -195
  55. package/src/setup.test.ts +0 -445
  56. package/src/signature.ts +0 -82
  57. package/src/types.ts +0 -195
  58. package/tsconfig.json +0 -16
package/src/setup.test.ts DELETED
@@ -1,445 +0,0 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { DEFAULT_ACCOUNT_ID } from "klaw/plugin-sdk/routing";
5
- import { describe, expect, it } from "vitest";
6
- import { resolveNextcloudTalkAccount } from "./accounts.js";
7
- import {
8
- clearNextcloudTalkAccountFields,
9
- nextcloudTalkDmPolicy,
10
- nextcloudTalkSetupAdapter,
11
- normalizeNextcloudTalkBaseUrl,
12
- setNextcloudTalkAccountConfig,
13
- validateNextcloudTalkBaseUrl,
14
- } from "./setup-core.js";
15
- import { nextcloudTalkSetupWizard } from "./setup-surface.js";
16
- import type { CoreConfig } from "./types.js";
17
-
18
- describe("nextcloud talk setup", () => {
19
- it("shows a bot install command with webhook, response, and reaction features", () => {
20
- expect(nextcloudTalkSetupWizard.introNote?.lines.join("\n")).toContain(
21
- "--feature webhook --feature response --feature reaction",
22
- );
23
- });
24
-
25
- it("normalizes and validates base urls", () => {
26
- expect(normalizeNextcloudTalkBaseUrl(" https://cloud.example.com/// ")).toBe(
27
- "https://cloud.example.com",
28
- );
29
- expect(normalizeNextcloudTalkBaseUrl(undefined)).toBe("");
30
-
31
- expect(validateNextcloudTalkBaseUrl("")).toBe("Required");
32
- expect(validateNextcloudTalkBaseUrl("cloud.example.com")).toBe(
33
- "URL must start with http:// or https://",
34
- );
35
- expect(validateNextcloudTalkBaseUrl("https://cloud.example.com")).toBeUndefined();
36
- });
37
-
38
- it("patches scoped account config and clears selected fields", () => {
39
- const cfg: CoreConfig = {
40
- channels: {
41
- "nextcloud-talk": {
42
- baseUrl: "https://cloud.example.com",
43
- botSecret: "top-secret",
44
- accounts: {
45
- work: {
46
- botSecret: "work-secret",
47
- botSecretFile: "/tmp/work-secret",
48
- apiPassword: "api-secret",
49
- },
50
- },
51
- },
52
- },
53
- };
54
-
55
- expect(
56
- setNextcloudTalkAccountConfig(cfg, DEFAULT_ACCOUNT_ID, {
57
- apiUser: "bot",
58
- }),
59
- ).toEqual({
60
- channels: {
61
- "nextcloud-talk": {
62
- enabled: true,
63
- baseUrl: "https://cloud.example.com",
64
- botSecret: "top-secret",
65
- apiUser: "bot",
66
- accounts: {
67
- work: {
68
- botSecret: "work-secret",
69
- botSecretFile: "/tmp/work-secret",
70
- apiPassword: "api-secret",
71
- },
72
- },
73
- },
74
- },
75
- });
76
-
77
- expect(clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"])).toEqual({
78
- channels: {
79
- "nextcloud-talk": {
80
- baseUrl: "https://cloud.example.com",
81
- accounts: {
82
- work: {
83
- botSecret: "work-secret",
84
- botSecretFile: "/tmp/work-secret",
85
- apiPassword: "api-secret",
86
- },
87
- },
88
- },
89
- },
90
- });
91
- expect(
92
- clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"]),
93
- ).not.toHaveProperty(["channels", "nextcloud-talk", "botSecret"]);
94
-
95
- expect(clearNextcloudTalkAccountFields(cfg, "work", ["botSecret", "botSecretFile"])).toEqual({
96
- channels: {
97
- "nextcloud-talk": {
98
- baseUrl: "https://cloud.example.com",
99
- botSecret: "top-secret",
100
- accounts: {
101
- work: {
102
- apiPassword: "api-secret",
103
- },
104
- },
105
- },
106
- },
107
- });
108
- });
109
-
110
- it("sets top-level DM policy state", () => {
111
- const base: CoreConfig = {
112
- channels: {
113
- "nextcloud-talk": {},
114
- },
115
- };
116
-
117
- expect(nextcloudTalkDmPolicy.getCurrent(base)).toBe("pairing");
118
- expect(nextcloudTalkDmPolicy.setPolicy(base, "open")).toEqual({
119
- channels: {
120
- "nextcloud-talk": {
121
- enabled: true,
122
- dmPolicy: "open",
123
- allowFrom: ["*"],
124
- },
125
- },
126
- });
127
- });
128
-
129
- it("honors named-account DM policy state and config keys", () => {
130
- const base: CoreConfig = {
131
- channels: {
132
- "nextcloud-talk": {
133
- dmPolicy: "disabled",
134
- accounts: {
135
- work: {
136
- baseUrl: "https://cloud.example.com",
137
- botSecret: "work-secret",
138
- dmPolicy: "allowlist",
139
- },
140
- },
141
- },
142
- },
143
- };
144
-
145
- expect(nextcloudTalkDmPolicy.getCurrent(base, "work")).toBe("allowlist");
146
- expect(nextcloudTalkDmPolicy.resolveConfigKeys?.(base, "work")).toEqual({
147
- policyKey: "channels.nextcloud-talk.accounts.work.dmPolicy",
148
- allowFromKey: "channels.nextcloud-talk.accounts.work.allowFrom",
149
- });
150
- });
151
-
152
- it("uses configured defaultAccount for omitted DM policy account context", () => {
153
- const base: CoreConfig = {
154
- channels: {
155
- "nextcloud-talk": {
156
- defaultAccount: "work",
157
- dmPolicy: "disabled",
158
- accounts: {
159
- work: {
160
- baseUrl: "https://cloud.example.com",
161
- botSecret: "work-secret",
162
- dmPolicy: "allowlist",
163
- },
164
- },
165
- },
166
- },
167
- };
168
-
169
- expect(nextcloudTalkDmPolicy.getCurrent(base)).toBe("allowlist");
170
- expect(nextcloudTalkDmPolicy.resolveConfigKeys?.(base)).toEqual({
171
- policyKey: "channels.nextcloud-talk.accounts.work.dmPolicy",
172
- allowFromKey: "channels.nextcloud-talk.accounts.work.allowFrom",
173
- });
174
-
175
- const next = nextcloudTalkDmPolicy.setPolicy(base, "open");
176
- expect(next.channels?.["nextcloud-talk"]?.dmPolicy).toBe("disabled");
177
- const workAccount = next.channels?.["nextcloud-talk"]?.accounts?.work as
178
- | { dmPolicy?: string; allowFrom?: Array<string | number> }
179
- | undefined;
180
- expect(workAccount?.dmPolicy).toBe("open");
181
- });
182
-
183
- it('writes open DM policy to the named account and preserves inherited allowFrom with "*"', () => {
184
- const next = nextcloudTalkDmPolicy.setPolicy(
185
- {
186
- channels: {
187
- "nextcloud-talk": {
188
- allowFrom: ["alice"],
189
- accounts: {
190
- work: {
191
- baseUrl: "https://cloud.example.com",
192
- botSecret: "work-secret",
193
- },
194
- },
195
- },
196
- },
197
- },
198
- "open",
199
- "work",
200
- );
201
-
202
- expect(next.channels?.["nextcloud-talk"]?.dmPolicy).toBeUndefined();
203
- const workAccount = next.channels?.["nextcloud-talk"]?.accounts?.work as
204
- | { dmPolicy?: string; allowFrom?: Array<string | number> }
205
- | undefined;
206
- expect(workAccount?.dmPolicy).toBe("open");
207
- expect(workAccount?.allowFrom).toEqual(["alice", "*"]);
208
- });
209
-
210
- it("validates env/default-account constraints and applies config patches", () => {
211
- const validateInput = nextcloudTalkSetupAdapter.validateInput;
212
- const applyAccountConfig = nextcloudTalkSetupAdapter.applyAccountConfig;
213
- expect(validateInput).toBeTypeOf("function");
214
- expect(applyAccountConfig).toBeTypeOf("function");
215
- if (!validateInput) {
216
- throw new Error("Expected Nextcloud Talk setup validateInput");
217
- }
218
-
219
- expect(
220
- validateInput({
221
- accountId: "work",
222
- input: { useEnv: true },
223
- } as never),
224
- ).toBe("NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account.");
225
-
226
- expect(
227
- validateInput({
228
- accountId: DEFAULT_ACCOUNT_ID,
229
- input: { useEnv: false, baseUrl: "", secret: "" },
230
- } as never),
231
- ).toBe("Nextcloud Talk requires bot secret or --secret-file (or --use-env).");
232
-
233
- expect(
234
- validateInput({
235
- accountId: DEFAULT_ACCOUNT_ID,
236
- input: { useEnv: false, secret: "secret", baseUrl: "" },
237
- } as never),
238
- ).toBe("Nextcloud Talk requires --base-url.");
239
-
240
- expect(
241
- applyAccountConfig({
242
- cfg: {
243
- channels: {
244
- "nextcloud-talk": {},
245
- },
246
- },
247
- accountId: DEFAULT_ACCOUNT_ID,
248
- input: {
249
- name: "Default",
250
- baseUrl: "https://cloud.example.com///",
251
- secret: "bot-secret",
252
- },
253
- } as never),
254
- ).toEqual({
255
- channels: {
256
- "nextcloud-talk": {
257
- enabled: true,
258
- name: "Default",
259
- baseUrl: "https://cloud.example.com",
260
- botSecret: "bot-secret",
261
- },
262
- },
263
- });
264
-
265
- expect(
266
- applyAccountConfig({
267
- cfg: {
268
- channels: {
269
- "nextcloud-talk": {
270
- accounts: {
271
- work: {
272
- botSecret: "old-secret",
273
- },
274
- },
275
- },
276
- },
277
- },
278
- accountId: "work",
279
- input: {
280
- name: "Work",
281
- useEnv: true,
282
- baseUrl: "https://cloud.example.com",
283
- },
284
- } as never),
285
- ).toEqual({
286
- channels: {
287
- "nextcloud-talk": {
288
- enabled: true,
289
- accounts: {
290
- work: {
291
- enabled: true,
292
- name: "Work",
293
- baseUrl: "https://cloud.example.com",
294
- },
295
- },
296
- },
297
- },
298
- });
299
- });
300
-
301
- it("clears stored bot secret fields when switching the default account to env", () => {
302
- type ApplyAccountConfigContext = Parameters<
303
- typeof nextcloudTalkSetupAdapter.applyAccountConfig
304
- >[0];
305
-
306
- const next = nextcloudTalkSetupAdapter.applyAccountConfig({
307
- cfg: {
308
- channels: {
309
- "nextcloud-talk": {
310
- enabled: true,
311
- baseUrl: "https://cloud.old.example",
312
- botSecret: "stored-secret",
313
- botSecretFile: "/tmp/secret.txt",
314
- },
315
- },
316
- },
317
- accountId: DEFAULT_ACCOUNT_ID,
318
- input: {
319
- baseUrl: "https://cloud.example.com",
320
- useEnv: true,
321
- },
322
- } as unknown as ApplyAccountConfigContext);
323
-
324
- expect(next.channels?.["nextcloud-talk"]?.baseUrl).toBe("https://cloud.example.com");
325
- expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret");
326
- expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile");
327
- });
328
-
329
- it("clears stored bot secret fields when the wizard switches to env", async () => {
330
- const credential = nextcloudTalkSetupWizard.credentials[0];
331
- const next = await credential.applyUseEnv?.({
332
- cfg: {
333
- channels: {
334
- "nextcloud-talk": {
335
- enabled: true,
336
- baseUrl: "https://cloud.example.com",
337
- botSecret: "stored-secret",
338
- botSecretFile: "/tmp/secret.txt",
339
- },
340
- },
341
- },
342
- accountId: DEFAULT_ACCOUNT_ID,
343
- });
344
-
345
- expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret");
346
- expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile");
347
- });
348
- });
349
-
350
- describe("resolveNextcloudTalkAccount", () => {
351
- it("matches normalized configured account ids", () => {
352
- const account = resolveNextcloudTalkAccount({
353
- cfg: {
354
- channels: {
355
- "nextcloud-talk": {
356
- accounts: {
357
- "Ops Team": {
358
- baseUrl: "https://cloud.example.com",
359
- botSecret: "bot-secret",
360
- },
361
- },
362
- },
363
- },
364
- } as CoreConfig,
365
- accountId: "ops-team",
366
- });
367
-
368
- expect(account.accountId).toBe("ops-team");
369
- expect(account.baseUrl).toBe("https://cloud.example.com");
370
- expect(account.secret).toBe("bot-secret");
371
- expect(account.secretSource).toBe("config");
372
- });
373
-
374
- it.runIf(process.platform !== "win32")("rejects symlinked botSecretFile paths", () => {
375
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "klaw-nextcloud-talk-"));
376
- const secretFile = path.join(dir, "secret.txt");
377
- const secretLink = path.join(dir, "secret-link.txt");
378
- fs.writeFileSync(secretFile, "bot-secret\n", "utf8");
379
- fs.symlinkSync(secretFile, secretLink);
380
-
381
- const cfg = {
382
- channels: {
383
- "nextcloud-talk": {
384
- baseUrl: "https://cloud.example.com",
385
- botSecretFile: secretLink,
386
- },
387
- },
388
- } as CoreConfig;
389
-
390
- const account = resolveNextcloudTalkAccount({ cfg });
391
- expect(account.secret).toBe("");
392
- expect(account.secretSource).toBe("none");
393
- fs.rmSync(dir, { recursive: true, force: true });
394
- });
395
-
396
- it("uses configured defaultAccount when accountId is omitted", () => {
397
- const account = resolveNextcloudTalkAccount({
398
- cfg: {
399
- channels: {
400
- "nextcloud-talk": {
401
- defaultAccount: "work",
402
- botSecret: "top-secret",
403
- accounts: {
404
- work: {
405
- baseUrl: "https://cloud.example.com",
406
- botSecret: "work-secret",
407
- },
408
- },
409
- },
410
- },
411
- } as CoreConfig,
412
- });
413
-
414
- expect(account.accountId).toBe("work");
415
- expect(account.baseUrl).toBe("https://cloud.example.com");
416
- expect(account.secret).toBe("work-secret");
417
- expect(account.secretSource).toBe("config");
418
- });
419
-
420
- it("uses configured defaultAccount for omitted setup configured state", () => {
421
- const configured = nextcloudTalkSetupWizard.status.resolveConfigured({
422
- cfg: {
423
- channels: {
424
- "nextcloud-talk": {
425
- defaultAccount: "work",
426
- baseUrl: "https://root.example.com",
427
- botSecret: "root-secret",
428
- accounts: {
429
- alerts: {
430
- baseUrl: "https://alerts.example.com",
431
- botSecret: "alerts-secret",
432
- },
433
- work: {
434
- baseUrl: "",
435
- botSecret: "",
436
- },
437
- },
438
- },
439
- },
440
- } as CoreConfig,
441
- });
442
-
443
- expect(configured).toBe(false);
444
- });
445
- });
package/src/signature.ts DELETED
@@ -1,82 +0,0 @@
1
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
- import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
3
- import type { NextcloudTalkWebhookHeaders } from "./types.js";
4
-
5
- const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
6
- const RANDOM_HEADER = "x-nextcloud-talk-random";
7
- const BACKEND_HEADER = "x-nextcloud-talk-backend";
8
-
9
- /**
10
- * Verify the HMAC-SHA256 signature of an incoming webhook request.
11
- * Signature is calculated as: HMAC-SHA256(random + body, secret)
12
- */
13
- export function verifyNextcloudTalkSignature(params: {
14
- signature: string;
15
- random: string;
16
- body: string;
17
- secret: string;
18
- }): boolean {
19
- const { signature, random, body, secret } = params;
20
- if (!signature || !random || !secret) {
21
- return false;
22
- }
23
-
24
- const expected = createHmac("sha256", secret)
25
- .update(random + body)
26
- .digest("hex");
27
-
28
- const expectedBuf = Buffer.from(expected, "utf8");
29
- const signatureBuf = Buffer.from(signature, "utf8");
30
-
31
- // Pad to equal length before constant-time comparison to prevent
32
- // leaking length information via early-return timing.
33
- // Note: digest("hex") always produces lowercase ASCII (64 bytes for SHA-256),
34
- // so expectedBuf is always 64 bytes — no variable-length concern on the expected side.
35
- const maxLen = Math.max(expectedBuf.length, signatureBuf.length);
36
- const paddedExpected = Buffer.alloc(maxLen);
37
- const paddedSignature = Buffer.alloc(maxLen);
38
- expectedBuf.copy(paddedExpected);
39
- signatureBuf.copy(paddedSignature);
40
-
41
- // Use crypto.timingSafeEqual instead of manual XOR loop to avoid
42
- // potential JIT-optimisation timing leaks in the JavaScript engine.
43
- const timingResult = timingSafeEqual(paddedExpected, paddedSignature);
44
- return expectedBuf.length === signatureBuf.length && timingResult;
45
- }
46
-
47
- /**
48
- * Extract webhook headers from an incoming request.
49
- */
50
- export function extractNextcloudTalkHeaders(
51
- headers: Record<string, string | string[] | undefined>,
52
- ): NextcloudTalkWebhookHeaders | null {
53
- const getHeader = (name: string): string | undefined => {
54
- const value = headers[name] ?? headers[normalizeLowercaseStringOrEmpty(name)];
55
- return Array.isArray(value) ? value[0] : value;
56
- };
57
-
58
- const signature = getHeader(SIGNATURE_HEADER);
59
- const random = getHeader(RANDOM_HEADER);
60
- const backend = getHeader(BACKEND_HEADER);
61
-
62
- if (!signature || !random || !backend) {
63
- return null;
64
- }
65
-
66
- return { signature, random, backend };
67
- }
68
-
69
- /**
70
- * Generate signature headers for an outbound request to Nextcloud Talk.
71
- */
72
- export function generateNextcloudTalkSignature(params: { body: string; secret: string }): {
73
- random: string;
74
- signature: string;
75
- } {
76
- const { body, secret } = params;
77
- const random = randomBytes(32).toString("hex");
78
- const signature = createHmac("sha256", secret)
79
- .update(random + body)
80
- .digest("hex");
81
- return { random, signature };
82
- }