@kodelyth/zalouser 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 (106) hide show
  1. package/README.md +120 -0
  2. package/api.ts +9 -0
  3. package/channel-plugin-api.ts +3 -0
  4. package/contract-api.ts +2 -0
  5. package/dist/accounts-DOefD_if.js +66 -0
  6. package/dist/accounts.runtime-KT101uuu.js +2 -0
  7. package/dist/api-DSWT4Dh_.js +133 -0
  8. package/dist/api.js +7 -0
  9. package/dist/channel-pby_3Sur.js +602 -0
  10. package/dist/channel-plugin-api.js +2 -0
  11. package/dist/channel.runtime-0aJ2O7Y8.js +25 -0
  12. package/dist/channel.setup-CqyWwqcQ.js +9 -0
  13. package/dist/contract-api.js +3 -0
  14. package/dist/doctor-contract-B9EvrW0j.js +128 -0
  15. package/dist/doctor-contract-api.js +2 -0
  16. package/dist/index.js +27 -0
  17. package/dist/monitor-CVtrUqyW.js +708 -0
  18. package/dist/runtime-api.js +19 -0
  19. package/dist/secret-contract-api.js +5 -0
  20. package/dist/security-audit-D_rftvs-.js +34 -0
  21. package/dist/send-uRjUB8mG.js +542 -0
  22. package/dist/session-route-CalHiv1d.js +92 -0
  23. package/dist/setup-entry.js +11 -0
  24. package/dist/setup-plugin-api.js +2 -0
  25. package/dist/setup-surface-Cfj4GQlB.js +360 -0
  26. package/dist/shared-DjK0e2FC.js +160 -0
  27. package/dist/test-api.js +5 -0
  28. package/dist/zalo-js-B80cRyDF.js +1285 -0
  29. package/doctor-contract-api.ts +1 -0
  30. package/index.ts +34 -0
  31. package/klaw.plugin.json +3 -286
  32. package/package.json +4 -4
  33. package/runtime-api.ts +62 -0
  34. package/secret-contract-api.ts +4 -0
  35. package/setup-entry.ts +9 -0
  36. package/setup-plugin-api.ts +2 -0
  37. package/src/accounts.runtime.ts +1 -0
  38. package/src/accounts.test-mocks.ts +14 -0
  39. package/src/accounts.test.ts +298 -0
  40. package/src/accounts.ts +136 -0
  41. package/src/channel-api.ts +16 -0
  42. package/src/channel.adapters.ts +432 -0
  43. package/src/channel.directory.test.ts +59 -0
  44. package/src/channel.runtime.ts +12 -0
  45. package/src/channel.sendpayload.test.ts +311 -0
  46. package/src/channel.setup.test.ts +30 -0
  47. package/src/channel.setup.ts +12 -0
  48. package/src/channel.test.ts +424 -0
  49. package/src/channel.ts +221 -0
  50. package/src/config-schema.ts +33 -0
  51. package/src/directory.ts +54 -0
  52. package/src/doctor-contract.ts +156 -0
  53. package/src/doctor.test.ts +87 -0
  54. package/src/doctor.ts +37 -0
  55. package/src/group-policy.test.ts +61 -0
  56. package/src/group-policy.ts +83 -0
  57. package/src/message-sid.test.ts +66 -0
  58. package/src/message-sid.ts +80 -0
  59. package/src/monitor.account-scope.test.ts +122 -0
  60. package/src/monitor.group-gating.test.ts +967 -0
  61. package/src/monitor.send-mocks.ts +20 -0
  62. package/src/monitor.ts +1057 -0
  63. package/src/probe.test.ts +60 -0
  64. package/src/probe.ts +35 -0
  65. package/src/qr-temp-file.ts +19 -0
  66. package/src/reaction.test.ts +19 -0
  67. package/src/reaction.ts +32 -0
  68. package/src/runtime.ts +9 -0
  69. package/src/security-audit.test.ts +83 -0
  70. package/src/security-audit.ts +71 -0
  71. package/src/send-receipt.ts +31 -0
  72. package/src/send.test.ts +424 -0
  73. package/src/send.ts +280 -0
  74. package/src/session-route.ts +121 -0
  75. package/src/setup-core.ts +36 -0
  76. package/src/setup-surface.test.ts +367 -0
  77. package/src/setup-surface.ts +481 -0
  78. package/src/setup-test-helpers.ts +42 -0
  79. package/src/shared.ts +92 -0
  80. package/src/status-issues.test.ts +31 -0
  81. package/src/status-issues.ts +55 -0
  82. package/src/test-helpers.ts +26 -0
  83. package/src/text-styles.test.ts +203 -0
  84. package/src/text-styles.ts +540 -0
  85. package/src/tool.test.ts +212 -0
  86. package/src/tool.ts +200 -0
  87. package/src/types.ts +127 -0
  88. package/src/zalo-js.credentials.test.ts +465 -0
  89. package/src/zalo-js.test-mocks.ts +89 -0
  90. package/src/zalo-js.ts +1889 -0
  91. package/src/zca-client.test.ts +27 -0
  92. package/src/zca-client.ts +259 -0
  93. package/src/zca-constants.ts +55 -0
  94. package/src/zca-js-exports.d.ts +22 -0
  95. package/test-api.ts +21 -0
  96. package/tsconfig.json +16 -0
  97. package/api.js +0 -7
  98. package/channel-plugin-api.js +0 -7
  99. package/contract-api.js +0 -7
  100. package/doctor-contract-api.js +0 -7
  101. package/index.js +0 -7
  102. package/runtime-api.js +0 -7
  103. package/secret-contract-api.js +0 -7
  104. package/setup-entry.js +0 -7
  105. package/setup-plugin-api.js +0 -7
  106. package/test-api.js +0 -7
@@ -0,0 +1,60 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { probeZalouser } from "./probe.js";
3
+ import { getZaloUserInfo } from "./zalo-js.js";
4
+
5
+ vi.mock("./zalo-js.js", () => ({
6
+ getZaloUserInfo: vi.fn(),
7
+ }));
8
+
9
+ const mockGetUserInfo = vi.mocked(getZaloUserInfo);
10
+
11
+ describe("probeZalouser", () => {
12
+ beforeEach(() => {
13
+ mockGetUserInfo.mockReset();
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ it("returns ok=true with user when authenticated", async () => {
21
+ mockGetUserInfo.mockResolvedValueOnce({
22
+ userId: "123",
23
+ displayName: "Alice",
24
+ });
25
+
26
+ await expect(probeZalouser("default")).resolves.toEqual({
27
+ ok: true,
28
+ user: { userId: "123", displayName: "Alice" },
29
+ });
30
+ });
31
+
32
+ it("returns not authenticated when no user info is returned", async () => {
33
+ mockGetUserInfo.mockResolvedValueOnce(null);
34
+ await expect(probeZalouser("default")).resolves.toEqual({
35
+ ok: false,
36
+ error: "Not authenticated",
37
+ });
38
+ });
39
+
40
+ it("returns error when user lookup throws", async () => {
41
+ mockGetUserInfo.mockRejectedValueOnce(new Error("network down"));
42
+ await expect(probeZalouser("default")).resolves.toEqual({
43
+ ok: false,
44
+ error: "network down",
45
+ });
46
+ });
47
+
48
+ it("times out when lookup takes too long", async () => {
49
+ vi.useFakeTimers();
50
+ mockGetUserInfo.mockReturnValueOnce(new Promise(() => undefined));
51
+
52
+ const pending = probeZalouser("default", 10);
53
+ await vi.advanceTimersByTimeAsync(1000);
54
+
55
+ await expect(pending).resolves.toEqual({
56
+ ok: false,
57
+ error: "Not authenticated",
58
+ });
59
+ });
60
+ });
package/src/probe.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { BaseProbeResult } from "klaw/plugin-sdk/channel-contract";
2
+ import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
3
+ import type { ZcaUserInfo } from "./types.js";
4
+ import { getZaloUserInfo } from "./zalo-js.js";
5
+
6
+ export type ZalouserProbeResult = BaseProbeResult<string> & {
7
+ user?: ZcaUserInfo;
8
+ };
9
+
10
+ export async function probeZalouser(
11
+ profile: string,
12
+ timeoutMs?: number,
13
+ ): Promise<ZalouserProbeResult> {
14
+ try {
15
+ const user = timeoutMs
16
+ ? await Promise.race([
17
+ getZaloUserInfo(profile),
18
+ new Promise<null>((resolve) =>
19
+ setTimeout(() => resolve(null), Math.max(timeoutMs, 1000)),
20
+ ),
21
+ ])
22
+ : await getZaloUserInfo(profile);
23
+
24
+ if (!user) {
25
+ return { ok: false, error: "Not authenticated" };
26
+ }
27
+
28
+ return { ok: true, user };
29
+ } catch (error) {
30
+ return {
31
+ ok: false,
32
+ error: formatErrorMessage(error),
33
+ };
34
+ }
35
+ }
@@ -0,0 +1,19 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { resolvePreferredKlawTmpDir } from "klaw/plugin-sdk/temp-path";
4
+
5
+ export async function writeQrDataUrlToTempFile(
6
+ qrDataUrl: string,
7
+ profile: string,
8
+ ): Promise<string | null> {
9
+ const trimmed = qrDataUrl.trim();
10
+ const match = trimmed.match(/^data:image\/png;base64,(.+)$/i);
11
+ const base64 = (match?.[1] ?? "").trim();
12
+ if (!base64) {
13
+ return null;
14
+ }
15
+ const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
16
+ const filePath = path.join(resolvePreferredKlawTmpDir(), `klaw-zalouser-qr-${safeProfile}.png`);
17
+ await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
18
+ return filePath;
19
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeZaloReactionIcon } from "./reaction.js";
3
+
4
+ describe("zalouser reaction alias normalization", () => {
5
+ it("maps common aliases", () => {
6
+ expect(normalizeZaloReactionIcon("like")).toBe("/-strong");
7
+ expect(normalizeZaloReactionIcon("👍")).toBe("/-strong");
8
+ expect(normalizeZaloReactionIcon("heart")).toBe("/-heart");
9
+ expect(normalizeZaloReactionIcon("😂")).toBe(":>");
10
+ });
11
+
12
+ it("defaults empty icon to like", () => {
13
+ expect(normalizeZaloReactionIcon("")).toBe("/-strong");
14
+ });
15
+
16
+ it("passes through unknown custom reactions", () => {
17
+ expect(normalizeZaloReactionIcon("/custom")).toBe("/custom");
18
+ });
19
+ });
@@ -0,0 +1,32 @@
1
+ import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
2
+ import { Reactions } from "./zca-constants.js";
3
+
4
+ const REACTION_ALIAS_MAP = new Map<string, string>([
5
+ ["like", Reactions.LIKE],
6
+ ["👍", Reactions.LIKE],
7
+ [":+1:", Reactions.LIKE],
8
+ ["heart", Reactions.HEART],
9
+ ["❤️", Reactions.HEART],
10
+ ["<3", Reactions.HEART],
11
+ ["haha", Reactions.HAHA],
12
+ ["laugh", Reactions.HAHA],
13
+ ["😂", Reactions.HAHA],
14
+ ["wow", Reactions.WOW],
15
+ ["😮", Reactions.WOW],
16
+ ["cry", Reactions.CRY],
17
+ ["😢", Reactions.CRY],
18
+ ["angry", Reactions.ANGRY],
19
+ ["😡", Reactions.ANGRY],
20
+ ]);
21
+
22
+ export function normalizeZaloReactionIcon(raw: string): string {
23
+ const trimmed = raw.trim();
24
+ if (!trimmed) {
25
+ return Reactions.LIKE;
26
+ }
27
+ return (
28
+ REACTION_ALIAS_MAP.get(normalizeLowercaseStringOrEmpty(trimmed)) ??
29
+ REACTION_ALIAS_MAP.get(trimmed) ??
30
+ trimmed
31
+ );
32
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { PluginRuntime } from "klaw/plugin-sdk/core";
2
+ import { createPluginRuntimeStore } from "klaw/plugin-sdk/runtime-store";
3
+
4
+ const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
5
+ createPluginRuntimeStore<PluginRuntime>({
6
+ pluginId: "zalouser",
7
+ errorMessage: "Zalouser runtime not initialized",
8
+ });
9
+ export { getZalouserRuntime, setZalouserRuntime };
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { collectZalouserSecurityAuditFindings } from "./security-audit.js";
3
+ import type { ResolvedZalouserAccount, ZalouserAccountConfig } from "./types.js";
4
+
5
+ function createAccount(config: ZalouserAccountConfig): ResolvedZalouserAccount {
6
+ return {
7
+ accountId: "default",
8
+ enabled: true,
9
+ profile: "default",
10
+ authenticated: true,
11
+ config,
12
+ };
13
+ }
14
+
15
+ describe("Zalouser security audit findings", () => {
16
+ const cases: Array<{
17
+ name: string;
18
+ config: ZalouserAccountConfig;
19
+ expectedSeverity: "info" | "warn";
20
+ expectedTitle: string;
21
+ expectedRemediation: string;
22
+ detailIncludes: string[];
23
+ detailExcludes?: string[];
24
+ }> = [
25
+ {
26
+ name: "warns when group routing contains mutable group entries",
27
+ config: {
28
+ enabled: true,
29
+ groups: {
30
+ "Ops Room": { enabled: true },
31
+ "group:g-123": { enabled: true },
32
+ },
33
+ } satisfies ZalouserAccountConfig,
34
+ expectedSeverity: "warn",
35
+ expectedTitle: "Zalouser group routing contains mutable group entries",
36
+ expectedRemediation:
37
+ "Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
38
+ detailIncludes: ["channels.zalouser.groups:Ops Room"],
39
+ detailExcludes: ["group:g-123"],
40
+ },
41
+ {
42
+ name: "marks mutable group routing as break-glass when dangerous matching is enabled",
43
+ config: {
44
+ enabled: true,
45
+ dangerouslyAllowNameMatching: true,
46
+ groups: {
47
+ "Ops Room": { enabled: true },
48
+ },
49
+ } satisfies ZalouserAccountConfig,
50
+ expectedSeverity: "info",
51
+ expectedTitle: "Zalouser group routing uses break-glass name matching",
52
+ expectedRemediation:
53
+ "Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching.",
54
+ detailIncludes: ["out-of-scope"],
55
+ },
56
+ ];
57
+
58
+ it.each(cases)("$name", (testCase) => {
59
+ const findings = collectZalouserSecurityAuditFindings({
60
+ account: createAccount(testCase.config),
61
+ accountId: "default",
62
+ orderedAccountIds: ["default"],
63
+ hasExplicitAccountPath: false,
64
+ });
65
+ const finding = findings.find(
66
+ (entry) => entry.checkId === "channels.zalouser.groups.mutable_entries",
67
+ );
68
+
69
+ if (!finding) {
70
+ throw new Error("expected mutable Zalo User group finding");
71
+ }
72
+ expect(finding.checkId).toBe("channels.zalouser.groups.mutable_entries");
73
+ expect(finding.severity).toBe(testCase.expectedSeverity);
74
+ expect(finding.title).toBe(testCase.expectedTitle);
75
+ expect(finding.remediation).toBe(testCase.expectedRemediation);
76
+ for (const snippet of testCase.detailIncludes) {
77
+ expect(finding.detail).toContain(snippet);
78
+ }
79
+ for (const snippet of testCase.detailExcludes ?? []) {
80
+ expect(finding.detail).not.toContain(snippet);
81
+ }
82
+ });
83
+ });
@@ -0,0 +1,71 @@
1
+ import { isDangerousNameMatchingEnabled } from "klaw/plugin-sdk/dangerous-name-runtime";
2
+ import type { ResolvedZalouserAccount } from "./accounts.js";
3
+
4
+ export function isZalouserMutableGroupEntry(raw: string): boolean {
5
+ const text = raw.trim();
6
+ if (!text || text === "*") {
7
+ return false;
8
+ }
9
+ const normalized = text
10
+ .replace(/^(zalouser|zlu):/i, "")
11
+ .replace(/^group:/i, "")
12
+ .trim();
13
+ if (!normalized) {
14
+ return false;
15
+ }
16
+ if (/^\d+$/.test(normalized)) {
17
+ return false;
18
+ }
19
+ return !/^g-\S+$/i.test(normalized);
20
+ }
21
+
22
+ export function collectZalouserSecurityAuditFindings(params: {
23
+ accountId?: string | null;
24
+ account: ResolvedZalouserAccount;
25
+ orderedAccountIds: string[];
26
+ hasExplicitAccountPath: boolean;
27
+ }) {
28
+ const zalouserCfg = params.account.config ?? {};
29
+ const accountId = params.accountId?.trim() || params.account.accountId || "default";
30
+ const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(zalouserCfg);
31
+ const zalouserPathPrefix =
32
+ params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
33
+ ? `channels.zalouser.accounts.${accountId}`
34
+ : "channels.zalouser";
35
+ const mutableGroupEntries = new Set<string>();
36
+ const groups = zalouserCfg.groups;
37
+ if (groups && typeof groups === "object" && !Array.isArray(groups)) {
38
+ for (const key of Object.keys(groups as Record<string, unknown>)) {
39
+ if (!isZalouserMutableGroupEntry(key)) {
40
+ continue;
41
+ }
42
+ mutableGroupEntries.add(`${zalouserPathPrefix}.groups:${key}`);
43
+ }
44
+ }
45
+ if (mutableGroupEntries.size === 0) {
46
+ return [];
47
+ }
48
+ const examples = Array.from(mutableGroupEntries).slice(0, 5);
49
+ const more =
50
+ mutableGroupEntries.size > examples.length
51
+ ? ` (+${mutableGroupEntries.size - examples.length} more)`
52
+ : "";
53
+ const severity: "info" | "warn" = dangerousNameMatchingEnabled ? "info" : "warn";
54
+ return [
55
+ {
56
+ checkId: "channels.zalouser.groups.mutable_entries",
57
+ severity,
58
+ title: dangerousNameMatchingEnabled
59
+ ? "Zalouser group routing uses break-glass name matching"
60
+ : "Zalouser group routing contains mutable group entries",
61
+ detail: dangerousNameMatchingEnabled
62
+ ? "Zalouser group-name routing is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
63
+ `Found: ${examples.join(", ")}${more}.`
64
+ : "Zalouser group auth is ID-only by default, so unresolved group-name or slug entries are ignored for auth and can drift from the intended trusted group. " +
65
+ `Found: ${examples.join(", ")}${more}.`,
66
+ remediation: dangerousNameMatchingEnabled
67
+ ? "Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching."
68
+ : "Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
69
+ },
70
+ ];
71
+ }
@@ -0,0 +1,31 @@
1
+ import {
2
+ createMessageReceiptFromOutboundResults,
3
+ type MessageReceipt,
4
+ type MessageReceiptPartKind,
5
+ } from "klaw/plugin-sdk/channel-message";
6
+
7
+ export function createZalouserSendReceipt(params: {
8
+ messageId?: string;
9
+ platformMessageIds?: readonly (string | null | undefined)[];
10
+ threadId?: string;
11
+ kind?: MessageReceiptPartKind;
12
+ }): MessageReceipt {
13
+ const platformMessageIds = (params.platformMessageIds ?? [params.messageId])
14
+ .map((messageId) => messageId?.trim())
15
+ .filter((messageId): messageId is string => Boolean(messageId));
16
+ const threadId = params.threadId?.trim();
17
+ return createMessageReceiptFromOutboundResults({
18
+ results: platformMessageIds.map((messageId) => {
19
+ const result: { channel: string; messageId: string; conversationId?: string } = {
20
+ channel: "zalouser",
21
+ messageId,
22
+ };
23
+ if (threadId) {
24
+ result.conversationId = threadId;
25
+ }
26
+ return result;
27
+ }),
28
+ ...(threadId ? { threadId } : {}),
29
+ kind: params.kind ?? "unknown",
30
+ });
31
+ }